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 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 13:40:17 +02:00
parent f4648cc382
commit f4503b0220
3 changed files with 93 additions and 23 deletions

View File

@@ -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 @@
</p>
</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 -->
<div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);">
<input
@@ -147,7 +175,8 @@
type="button"
aria-label="Wählen"
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
</button>
@@ -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
</button>

View File

@@ -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));
});
});

View File

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