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:
2026-04-08 22:42:10 +02:00
parent 36ae82af5d
commit 25c575c167
3 changed files with 293 additions and 0 deletions

View 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>