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:
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user