feat(planner): wire variety-aware suggestions into RecipePicker for empty slots #47

Merged
marcel merged 30 commits from feat/issue-46-wire-suggestions-recipe-picker into master 2026-04-09 16:33:12 +02:00
3 changed files with 93 additions and 23 deletions
Showing only changes of commit f4503b0220 - Show all commits

View File

@@ -8,6 +8,9 @@
suggestions = [], suggestions = [],
allRecipes = [], allRecipes = [],
isLoading = false, isLoading = false,
isDisabled = false,
excludeRecipeId,
replacingRecipe,
onpick onpick
}: { }: {
planId: string; planId: string;
@@ -16,23 +19,32 @@
suggestions: Suggestion[]; suggestions: Suggestion[];
allRecipes: Recipe[]; allRecipes: Recipe[];
isLoading?: boolean; isLoading?: boolean;
isDisabled?: boolean;
excludeRecipeId?: string;
replacingRecipe?: { name: string; meta?: string };
onpick: (recipeId: string, recipeName: string) => void; onpick: (recipeId: string, recipeName: string) => void;
} = $props(); } = $props();
let searchQuery = $state(''); let searchQuery = $state('');
let topRecommendations = $derived( let topRecommendations = $derived(
suggestions.filter((s) => s.scoreDelta > 0).slice(0, 5) suggestions
.filter((s) => s.scoreDelta > 0 && s.recipe.id !== excludeRecipeId)
.slice(0, 5)
); );
let scoreMap = $derived( let scoreMap = $derived(
new Map(suggestions.map((s) => [s.recipe.id, s])) new Map(suggestions.map((s) => [s.recipe.id, s]))
); );
let baseRecipes = $derived(
excludeRecipeId ? allRecipes.filter((r) => r.id !== excludeRecipeId) : allRecipes
);
let filteredRecipes = $derived( let filteredRecipes = $derived(
searchQuery.trim() === '' searchQuery.trim() === ''
? allRecipes ? baseRecipes
: allRecipes.filter((r) => : baseRecipes.filter((r) =>
r.name.toLowerCase().includes(searchQuery.toLowerCase()) r.name.toLowerCase().includes(searchQuery.toLowerCase())
) )
); );
@@ -86,6 +98,22 @@
</p> </p>
</div> </div>
<!-- Wird ersetzt banner (swap context) -->
{#if replacingRecipe}
<div style="background: var(--orange-tint); border-bottom: 1px solid #FBCDA4; padding: 8px 12px;">
<p style="font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--orange-dark); margin: 0 0 2px 0; font-family: var(--font-sans);">
Wird ersetzt
</p>
<span
data-testid="replacing-name"
title={replacingRecipe.name}
style="display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-display); font-size: 13px; text-decoration: line-through; opacity: 0.6; color: var(--color-text);"
>
{replacingRecipe.name}{#if replacingRecipe.meta} · {replacingRecipe.meta}{/if}
</span>
</div>
{/if}
<!-- Search --> <!-- Search -->
<div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);"> <div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);">
<input <input
@@ -147,7 +175,8 @@
type="button" type="button"
aria-label="Wählen" aria-label="Wählen"
onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)} onclick={() => onpick(suggestion.recipe.id, suggestion.recipe.name)}
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: pointer;" disabled={isDisabled}
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: {isDisabled ? 'default' : 'pointer'}; opacity: {isDisabled ? '0.4' : '1'};"
> >
+ Wählen + Wählen
</button> </button>
@@ -194,7 +223,8 @@
type="button" type="button"
aria-label="Wählen" aria-label="Wählen"
onclick={() => onpick(recipe.id, recipe.name)} onclick={() => onpick(recipe.id, recipe.name)}
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: pointer;" disabled={isDisabled}
style="flex-shrink: 0; font-family: var(--font-sans); font-size: 10px; font-weight: 500; padding: 4px 8px; border-radius: var(--radius-md); background: var(--green-dark); color: #fff; border: none; cursor: {isDisabled ? 'default' : 'pointer'}; opacity: {isDisabled ? '0.4' : '1'};"
> >
+ Wählen + Wählen
</button> </button>

View File

@@ -174,4 +174,40 @@ describe('RecipePicker', () => {
expect(screen.queryByTestId('suggestions-loading')).toBeNull(); expect(screen.queryByTestId('suggestions-loading')).toBeNull();
expect(screen.getByText(/Empfohlen/i)).toBeTruthy(); expect(screen.getByText(/Empfohlen/i)).toBeTruthy();
}); });
it('shows Wird ersetzt banner when replacingRecipe is provided', () => {
render(RecipePicker, { props: { ...baseProps, replacingRecipe: { name: 'Pasta', meta: '20 Min · easy' } } });
expect(screen.getByText(/Wird ersetzt/i)).toBeTruthy();
expect(screen.getByTestId('replacing-name').textContent).toContain('Pasta');
});
it('hides Wird ersetzt banner when replacingRecipe is not provided', () => {
render(RecipePicker, { props: baseProps });
expect(screen.queryByText(/Wird ersetzt/i)).toBeNull();
});
it('excludes recipe from Alle Rezepte when excludeRecipeId is set', () => {
render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 'r2' } });
expect(screen.queryByText('Spaghetti Carbonara')).toBeNull();
expect(screen.getByText('Beef Bourguignon')).toBeTruthy();
expect(screen.getByText('Tomatensuppe')).toBeTruthy();
});
it('excludes recipe from Empfohlen when excludeRecipeId matches a positive-delta suggestion', () => {
// s1 (Lachsfilet, scoreDelta=1.5) would normally appear in Empfohlen
render(RecipePicker, { props: { ...baseProps, excludeRecipeId: 's1' } });
expect(screen.queryByText('Lachsfilet')).toBeNull();
});
it('disables Wählen buttons when isDisabled is true', () => {
render(RecipePicker, { props: { ...baseProps, isDisabled: true } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(true));
});
it('enables Wählen buttons when isDisabled is false', () => {
render(RecipePicker, { props: { ...baseProps, isDisabled: false } });
const buttons = screen.getAllByRole('button', { name: /Wählen/i });
buttons.forEach((btn) => expect((btn as HTMLButtonElement).disabled).toBe(false));
});
}); });

View File

@@ -66,6 +66,7 @@
const activePickerDate = $derived( const activePickerDate = $derived(
pickerOpen ? selectedDay pickerOpen ? selectedDay
: swapSheetOpen ? selectedDay
: panelState.kind === 'recipe-picker' ? panelState.date : panelState.kind === 'recipe-picker' ? panelState.date
: null : null
); );
@@ -357,18 +358,18 @@
selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null, selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null,
selectedSlot.recipe?.effort ?? null selectedSlot.recipe?.effort ?? null
].filter(Boolean).join(' · ')} ].filter(Boolean).join(' · ')}
<div style="padding: 16px;"> <RecipePicker
<SwapSuggestionList planId={weekPlan?.id ?? ''}
replacingName={selectedSlot.recipe?.name ?? ''} date={selectedDay}
replacingMeta={replacingMeta || undefined} dateLabel={formatDayLabel(selectedDay)}
recipes={sortedRecipes} suggestions={suggestions}
{currentWeekRecipeIds} allRecipes={data.recipes}
excludeRecipeId={selectedSlot.recipe?.id} isLoading={isLoadingSuggestions}
isLoading={swapLoading} isDisabled={swapLoading}
onpick={handleSwapPick} excludeRecipeId={selectedSlot.recipe?.id}
oncancel={() => (swapSheetOpen = false)} replacingRecipe={selectedSlot.recipe ? { name: selectedSlot.recipe.name, meta: replacingMeta || undefined } : undefined}
/> onpick={handleSwapPick}
</div> />
</BottomSheet> </BottomSheet>
</div> </div>
@@ -607,13 +608,16 @@
pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null, pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null,
pickerSlot.recipe.effort ?? null pickerSlot.recipe.effort ?? null
].filter(Boolean).join(' · ')} ].filter(Boolean).join(' · ')}
<div class="flex-1 overflow-y-auto -mx-4 -mb-4 px-4"> <div class="flex-1 overflow-y-auto -mx-4 -mb-4">
<SwapSuggestionList <RecipePicker
replacingName={pickerSlot.recipe.name} planId={weekPlan?.id ?? ''}
replacingMeta={replacingMeta || undefined} date={pickerDate}
recipes={sortedRecipes} dateLabel={formatDayLabel(pickerDate)}
{currentWeekRecipeIds} suggestions={suggestions}
allRecipes={data.recipes}
isLoading={isLoadingSuggestions}
excludeRecipeId={pickerSlot.recipe.id} excludeRecipeId={pickerSlot.recipe.id}
replacingRecipe={{ name: pickerSlot.recipe.name, meta: replacingMeta || undefined }}
onpick={handleRecipePick} onpick={handleRecipePick}
/> />
</div> </div>