feat(planner): wire variety-aware suggestions into RecipePicker for empty slots #47
@@ -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}
|
||||||
|
isLoading={isLoadingSuggestions}
|
||||||
|
isDisabled={swapLoading}
|
||||||
excludeRecipeId={selectedSlot.recipe?.id}
|
excludeRecipeId={selectedSlot.recipe?.id}
|
||||||
isLoading={swapLoading}
|
replacingRecipe={selectedSlot.recipe ? { name: selectedSlot.recipe.name, meta: replacingMeta || undefined } : undefined}
|
||||||
onpick={handleSwapPick}
|
onpick={handleSwapPick}
|
||||||
oncancel={() => (swapSheetOpen = false)}
|
|
||||||
/>
|
/>
|
||||||
</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