- +server.ts: pass topN=100 so all recipes are scored in one request - RecipePicker: Empfohlen keeps top 5 with scoreDelta > 0; builds a scoreMap from all suggestions; shows green/yellow/red delta badge on every recipe in Alle Rezepte that has a score entry - Extracted scoreBadge snippet to avoid duplication between sections Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
213 lines
6.8 KiB
Svelte
213 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
import type { Recipe, Suggestion } from '$lib/planner/types';
|
|
|
|
let {
|
|
planId,
|
|
date,
|
|
dateLabel,
|
|
suggestions = [],
|
|
allRecipes = [],
|
|
isLoading = false,
|
|
onpick
|
|
}: {
|
|
planId: string;
|
|
date: string;
|
|
dateLabel: string;
|
|
suggestions: Suggestion[];
|
|
allRecipes: Recipe[];
|
|
isLoading?: boolean;
|
|
onpick: (recipeId: string, recipeName: string) => void;
|
|
} = $props();
|
|
|
|
let searchQuery = $state('');
|
|
|
|
let topRecommendations = $derived(
|
|
suggestions.filter((s) => s.scoreDelta > 0).slice(0, 5)
|
|
);
|
|
|
|
let scoreMap = $derived(
|
|
new Map(suggestions.map((s) => [s.recipe.id, s]))
|
|
);
|
|
|
|
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>
|
|
|
|
{#snippet scoreBadge(recipeId: string, delta: number, hasConflict: boolean)}
|
|
{#if delta > 0}
|
|
<span
|
|
data-testid="badge-{recipeId}"
|
|
data-type="good"
|
|
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--green-tint); color: var(--green-dark);"
|
|
>
|
|
↑ +{delta.toFixed(1)} Punkte
|
|
</span>
|
|
{:else if hasConflict}
|
|
<span
|
|
data-testid="badge-{recipeId}"
|
|
data-type="bad"
|
|
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--red-tint, #fdecea); color: var(--color-error, #d9534f);"
|
|
>
|
|
↓ {delta.toFixed(1)} Punkte
|
|
</span>
|
|
{:else}
|
|
<span
|
|
data-testid="badge-{recipeId}"
|
|
data-type="neutral"
|
|
style="display: inline-block; margin-top: 3px; font-size: 9px; font-weight: 500; padding: 1px 5px; border-radius: 3px; background: var(--yellow-tint); color: var(--yellow-text);"
|
|
>
|
|
= {delta.toFixed(1)} Punkte
|
|
</span>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<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 isLoading}
|
|
<div data-testid="suggestions-loading">
|
|
{#each [1, 2, 3] as i (i)}
|
|
<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;">
|
|
<div
|
|
style="height: 12px; width: 60%; border-radius: 3px; background: var(--color-subtle); animation: pulse 1.5s ease-in-out infinite;"
|
|
></div>
|
|
<div
|
|
style="height: 9px; width: 35%; border-radius: 3px; background: var(--color-subtle); margin-top: 4px; animation: pulse 1.5s ease-in-out infinite;"
|
|
></div>
|
|
</div>
|
|
<div
|
|
style="height: 26px; width: 56px; border-radius: var(--radius-md); background: var(--color-subtle); animation: pulse 1.5s ease-in-out infinite;"
|
|
></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else if topRecommendations.length > 0}
|
|
<div data-testid="empfohlen-section">
|
|
<div
|
|
style="font-size: 11px; 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 topRecommendations as suggestion (suggestion.recipe.id)}
|
|
{@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}
|
|
{@render scoreBadge(suggestion.recipe.id, suggestion.scoreDelta ?? 0, suggestion.hasConflict)}
|
|
</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-dark); color: #fff; border: none; cursor: pointer;"
|
|
>
|
|
+ Wählen
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Alle Rezepte section -->
|
|
<div data-testid="alle-rezepte-section">
|
|
<div
|
|
style="font-size: 11px; 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)}
|
|
{@const score = scoreMap.get(recipe.id)}
|
|
<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}
|
|
{#if score}
|
|
{@render scoreBadge(recipe.id, score.scoreDelta ?? 0, score.hasConflict)}
|
|
{/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-dark); color: #fff; border: none; cursor: pointer;"
|
|
>
|
|
+ Wählen
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
}
|
|
</style>
|