feat(planner): wire variety-aware suggestions into RecipePicker for empty slots #47
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user