feat(planner): add RecipePicker component (C4) and suggestions API endpoint
C4 sheet content: Empfohlen section with variety delta badges, Alle Rezepte with client-side search filter. GET /planner endpoint proxies suggestions to backend for lazy client-side loading. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
168
frontend/src/lib/planner/RecipePicker.svelte
Normal file
168
frontend/src/lib/planner/RecipePicker.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
interface Recipe {
|
||||
id: string;
|
||||
name: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
recipe: Recipe;
|
||||
simulatedScore: number;
|
||||
}
|
||||
|
||||
let {
|
||||
planId,
|
||||
date,
|
||||
dateLabel,
|
||||
currentVarietyScore = 0,
|
||||
suggestions = [],
|
||||
allRecipes = [],
|
||||
onpick
|
||||
}: {
|
||||
planId: string;
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
currentVarietyScore?: number;
|
||||
suggestions: Suggestion[];
|
||||
allRecipes: Recipe[];
|
||||
onpick: (recipeId: string, recipeName: string) => void;
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
|
||||
let filteredRecipes = $derived(
|
||||
searchQuery.trim() === ''
|
||||
? allRecipes
|
||||
: allRecipes.filter((r) =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
|
||||
function recipeMetadata(recipe: Recipe): string {
|
||||
return [
|
||||
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
|
||||
recipe.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="background: var(--color-page); font-family: var(--font-sans);">
|
||||
<!-- Header -->
|
||||
<div style="padding: 10px 12px 6px; border-bottom: 1px solid var(--color-border);">
|
||||
<p style="font-family: var(--font-display); font-size: 14px; font-weight: 500; color: var(--color-text); margin: 0;">
|
||||
Rezept wählen
|
||||
</p>
|
||||
<p style="font-size: 11px; color: var(--color-text-muted); margin: 2px 0 0;">
|
||||
{dateLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div style="padding: 8px 12px; border-bottom: 1px solid var(--color-border);">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Rezept suchen…"
|
||||
style="width: 100%; box-sizing: border-box; padding: 5px 8px; font-size: 11px; font-family: var(--font-sans); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-surface); color: var(--color-text);"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empfohlen section -->
|
||||
{#if suggestions.length > 0}
|
||||
<div
|
||||
style="font-size: 7px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Empfohlen · Beste Abwechslung
|
||||
</div>
|
||||
|
||||
{#each suggestions as suggestion (suggestion.recipe.id)}
|
||||
{@const delta = suggestion.simulatedScore - currentVarietyScore}
|
||||
{@const meta = recipeMetadata(suggestion.recipe)}
|
||||
<div
|
||||
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||
>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
{suggestion.recipe.name}
|
||||
</p>
|
||||
{#if meta}
|
||||
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
{#if delta > 0}
|
||||
<span
|
||||
data-testid="badge-{suggestion.recipe.id}"
|
||||
data-type="good"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
|
||||
>
|
||||
↑ +{delta.toFixed(0)} Punkte
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
data-testid="badge-{suggestion.recipe.id}"
|
||||
data-type="warning"
|
||||
style="display: inline-block; margin-top: 3px; font-size: 8px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
|
||||
>
|
||||
⚠ Variationskonflikt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
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); color: #fff; border: none; cursor: pointer;"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- Alle Rezepte section -->
|
||||
<div
|
||||
style="font-size: 7px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--color-text-muted); padding: 5px 12px 3px; background: var(--color-subtle);"
|
||||
>
|
||||
Alle Rezepte
|
||||
</div>
|
||||
|
||||
{#if filteredRecipes.length === 0}
|
||||
<p style="padding: 10px 12px; font-size: 11px; color: var(--color-text-muted); margin: 0;">
|
||||
Keine Treffer
|
||||
</p>
|
||||
{:else}
|
||||
{#each filteredRecipes as recipe (recipe.id)}
|
||||
{@const meta = recipeMetadata(recipe)}
|
||||
<div
|
||||
style="padding: 7px 12px; border-bottom: 1px solid var(--color-subtle); display: flex; align-items: center; gap: 8px;"
|
||||
>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<p
|
||||
style="font-family: var(--font-display); font-size: 12px; font-weight: 400; color: var(--color-text); margin: 0;"
|
||||
>
|
||||
{recipe.name}
|
||||
</p>
|
||||
{#if meta}
|
||||
<p style="font-size: 9px; color: var(--color-text-muted); margin: 1px 0 0;">
|
||||
{meta}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
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); color: #fff; border: none; cursor: pointer;"
|
||||
>
|
||||
+ Wählen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user