From f4503b0220c88c6e74affb2f6226a74b9d6ed47b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 9 Apr 2026 13:40:17 +0200 Subject: [PATCH] feat(planner): show variety score in swap menu via RecipePicker Replace SwapSuggestionList with RecipePicker in both mobile and desktop swap contexts. RecipePicker now accepts excludeRecipeId, replacingRecipe, and isDisabled props. Mobile swap sheet also triggers suggestion fetch via activePickerDate so green/yellow/red score badges appear during swap. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/RecipePicker.svelte | 40 ++++++++++++++++--- frontend/src/lib/planner/RecipePicker.test.ts | 36 +++++++++++++++++ .../src/routes/(app)/planner/+page.svelte | 40 ++++++++++--------- 3 files changed, 93 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte index 47546b0..185f2bd 100644 --- a/frontend/src/lib/planner/RecipePicker.svelte +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -8,6 +8,9 @@ suggestions = [], allRecipes = [], isLoading = false, + isDisabled = false, + excludeRecipeId, + replacingRecipe, onpick }: { planId: string; @@ -16,23 +19,32 @@ suggestions: Suggestion[]; allRecipes: Recipe[]; isLoading?: boolean; + isDisabled?: boolean; + excludeRecipeId?: string; + replacingRecipe?: { name: string; meta?: string }; onpick: (recipeId: string, recipeName: string) => void; } = $props(); let searchQuery = $state(''); 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( new Map(suggestions.map((s) => [s.recipe.id, s])) ); + let baseRecipes = $derived( + excludeRecipeId ? allRecipes.filter((r) => r.id !== excludeRecipeId) : allRecipes + ); + let filteredRecipes = $derived( searchQuery.trim() === '' - ? allRecipes - : allRecipes.filter((r) => + ? baseRecipes + : baseRecipes.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()) ) ); @@ -86,6 +98,22 @@

+ + {#if replacingRecipe} +
+

+ Wird ersetzt +

+ + {replacingRecipe.name}{#if replacingRecipe.meta} · {replacingRecipe.meta}{/if} + +
+ {/if} +
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 @@ -194,7 +223,8 @@ type="button" aria-label="Wählen" 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 diff --git a/frontend/src/lib/planner/RecipePicker.test.ts b/frontend/src/lib/planner/RecipePicker.test.ts index 3036488..1100469 100644 --- a/frontend/src/lib/planner/RecipePicker.test.ts +++ b/frontend/src/lib/planner/RecipePicker.test.ts @@ -174,4 +174,40 @@ describe('RecipePicker', () => { expect(screen.queryByTestId('suggestions-loading')).toBeNull(); 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)); + }); }); diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index 5fa8284..0070032 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -66,6 +66,7 @@ const activePickerDate = $derived( pickerOpen ? selectedDay + : swapSheetOpen ? selectedDay : panelState.kind === 'recipe-picker' ? panelState.date : null ); @@ -357,18 +358,18 @@ selectedSlot.recipe?.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null, selectedSlot.recipe?.effort ?? null ].filter(Boolean).join(' · ')} -
- (swapSheetOpen = false)} - /> -
+
@@ -607,13 +608,16 @@ pickerSlot.recipe.cookTimeMin ? `${pickerSlot.recipe.cookTimeMin} Min` : null, pickerSlot.recipe.effort ?? null ].filter(Boolean).join(' · ')} -
- +