feat(variety): implement C3 variety review screen (Issue #28)

- Add /planner/variety route with mobile stacked + desktop 2-column layout
- Implement VarietyScoreHero: Fraunces score display + progress bar + color-coded description
- Implement ScoreBreakdownList: 3 sub-score rows (protein diversity, ingredient overlap, effort balance)
- Implement VarietyWarningCards: yellow-tint warning cards derived from API tagRepeats/ingredientOverlaps
- Implement EffortBar: proportional colored segments (Easy/Medium/Hard) with ×N labels
- Desktop: protein grid (7 columns, repeat highlight with yellow ring) + effort bar in right panel
- Client-side sub-score derivation from VarietyScoreResponse (tagged for TODO to move to API)
- 26 new tests across 5 components + server load function; 455 tests total, 0 type errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 11:23:29 +02:00
parent 7c07bc443b
commit 8ad636f825
11 changed files with 727 additions and 0 deletions

View File

@@ -0,0 +1,277 @@
<script lang="ts">
import VarietyScoreHero from '$lib/planner/VarietyScoreHero.svelte';
import ScoreBreakdownList from '$lib/planner/ScoreBreakdownList.svelte';
import VarietyWarningCards from '$lib/planner/VarietyWarningCards.svelte';
import EffortBar from '$lib/planner/EffortBar.svelte';
import { formatDayLabel } from '$lib/planner/week';
let { data } = $props();
let weekPlan = $derived(data.weekPlan);
let varietyScore = $derived(data.varietyScore);
let weekStart = $derived(data.weekStart);
let score = $derived(varietyScore?.score ?? 0);
// Derive effort distribution from week plan slots
let effortCounts = $derived(() => {
const slots = weekPlan?.slots ?? [];
let easy = 0, medium = 0, hard = 0;
for (const slot of slots) {
const effort = slot.recipe?.effort?.toLowerCase() ?? '';
if (effort === 'easy' || effort === 'einfach') easy++;
else if (effort === 'medium' || effort === 'mittel') medium++;
else if (effort === 'hard' || effort === 'aufwändig') hard++;
}
return { easy, medium, hard };
});
// Derive sub-scores from API data
// TODO: replace with API-provided sub-scores once backend supports them.
let subScores = $derived(() => {
const proteinRepeats = (varietyScore?.tagRepeats ?? []).filter(
(t: any) => t.tagType === 'protein'
).length;
const ingredientOverlapCount = (varietyScore?.ingredientOverlaps ?? []).length;
const { easy, medium, hard } = effortCounts();
const total = easy + medium + hard;
// Effort balance: ideal is roughly equal split; penalise extreme distributions
const effortBalance =
total === 0
? 10
: Math.round(Math.max(0, 10 - Math.abs(easy - hard) * 1.5));
return {
proteinDiversity: Math.max(0, Math.round(10 - proteinRepeats * 2)),
ingredientOverlap: Math.max(0, Math.round(10 - ingredientOverlapCount * 1.5)),
effortBalance: Math.min(10, effortBalance)
};
});
// Build warning list from API data
let warnings = $derived(() => {
const result: { title: string; explanation: string }[] = [];
// Protein repeats
for (const repeat of varietyScore?.tagRepeats ?? []) {
if ((repeat.days?.length ?? 0) > 1) {
const days = (repeat.days ?? []).map((d: string) => d).join(', ');
result.push({
title: `${repeat.tagName} mehrfach diese Woche`,
explanation: `${days} — erwäge einen Tausch für mehr Protein-Abwechslung.`
});
}
}
// Ingredient overlaps
for (const overlap of varietyScore?.ingredientOverlaps ?? []) {
if ((overlap.days?.length ?? 0) > 1) {
const days = (overlap.days ?? []).join(', ');
result.push({
title: `${overlap.ingredientName} in mehreren Gerichten`,
explanation: `${days} — sorge für Zutaten-Abwechslung.`
});
}
}
// Duplicate recipes in plan
for (const name of varietyScore?.duplicatesInPlan ?? []) {
result.push({
title: `${name} doppelt geplant`,
explanation: 'Dasselbe Rezept erscheint mehrfach — tausche eines aus.'
});
}
return result;
});
// Protein grid: map protein tags to days of the week
let proteinByDay = $derived(() => {
const map: Record<string, string> = {};
for (const repeat of varietyScore?.tagRepeats ?? []) {
if (repeat.tagType === 'protein') {
for (const day of repeat.days ?? []) {
map[day] = repeat.tagName ?? '';
}
}
}
return map;
});
// Days of the week abbreviations for protein grid
const weekDayAbbrs = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
const weekDayKeys = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'];
</script>
<svelte:head>
<title>Abwechslung überprüfen — Mealplan</title>
</svelte:head>
<!-- Mobile layout -->
<div class="flex h-full flex-col lg:hidden">
<!-- Topbar -->
<header class="sticky top-0 z-10 flex items-center gap-3 border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
<a
href="/planner?week={weekStart}"
aria-label="Zurück zum Planer"
class="font-[var(--font-sans)] text-[20px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
</a>
<h1 class="font-[var(--font-display)] text-[18px] font-[300] text-[var(--color-text)]">
Abwechslungs-Analyse
</h1>
</header>
<div class="flex-1 overflow-y-auto px-4 pb-8 pt-5">
{#if !varietyScore}
<div class="flex flex-col items-center justify-center py-16 text-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Noch keine Gerichte geplant. Plane zuerst einige Mahlzeiten.
</p>
<a
href="/planner?week={weekStart}"
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
>
Zum Wochenplaner →
</a>
</div>
{:else}
<!-- Big score -->
<div class="mb-6">
<VarietyScoreHero {score} />
</div>
<!-- Sub-scores -->
<div class="mb-6">
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Bewertung im Detail
</h2>
<ScoreBreakdownList subScores={subScores()} />
</div>
<!-- Warnings -->
{#if warnings().length > 0}
<div class="space-y-3">
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Hinweise
</h2>
<VarietyWarningCards warnings={warnings()} />
</div>
{/if}
{/if}
</div>
</div>
<!-- Desktop layout -->
<div class="hidden h-screen lg:flex lg:flex-col">
<!-- Topbar with breadcrumb -->
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
<a
href="/planner?week={weekStart}"
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>
Planer
</a>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">/</span>
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">
Abwechslungs-Analyse
</h1>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar (224px) — nav placeholder for consistency with C1 -->
<aside class="hidden w-[224px] flex-shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface)] xl:block">
</aside>
<!-- Main content -->
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-8 py-6">
{#if !varietyScore}
<div class="flex h-full flex-col items-center justify-center">
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
Noch keine Gerichte geplant.
</p>
<a
href="/planner?week={weekStart}"
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
>
Zum Wochenplaner →
</a>
</div>
{:else}
<!-- Top section: 2 columns -->
<div class="flex gap-8">
<!-- Left: score + sub-scores -->
<div class="flex-1">
<VarietyScoreHero {score} />
<div class="mt-6">
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Bewertung im Detail
</h2>
<ScoreBreakdownList subScores={subScores()} />
</div>
</div>
<!-- Right (320px): protein grid + effort bar -->
<div class="w-[320px] flex-shrink-0 space-y-6">
<!-- Protein grid -->
<div>
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Protein-Verteilung
</h2>
<div class="grid grid-cols-7 gap-[6px]">
{#each weekDayAbbrs as abbr, i}
{@const key = weekDayKeys[i]}
{@const protein = proteinByDay()[key]}
{@const isRepeated = protein && Object.values(proteinByDay()).filter((p) => p === protein).length > 1}
<div class="flex flex-col items-center gap-1">
<span class="font-[var(--font-sans)] text-[10px] text-[var(--color-text-muted)]">{abbr}</span>
<div
data-testid="protein-cell"
data-protein={protein ?? 'none'}
class="flex h-[44px] w-full items-center justify-center rounded-[var(--radius-sm)] text-[10px] font-medium
{protein
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
: 'bg-[var(--color-subtle)] text-[var(--color-text-muted)]'}
{isRepeated ? 'ring-2 ring-[var(--yellow)]' : ''}"
>
{protein ? protein.slice(0, 3) : '—'}
</div>
</div>
{/each}
</div>
</div>
<!-- Effort bar -->
<div>
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Aufwandsverteilung
</h2>
{#if (effortCounts().easy + effortCounts().medium + effortCounts().hard) > 0}
<EffortBar
easy={effortCounts().easy}
medium={effortCounts().medium}
hard={effortCounts().hard}
/>
{:else}
<p class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
Noch keine Gerichte geplant.
</p>
{/if}
</div>
</div>
</div>
<!-- Bottom: warnings, full width -->
{#if warnings().length > 0}
<div class="mt-8 space-y-3">
<h2 class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
Hinweise
</h2>
<VarietyWarningCards warnings={warnings()} />
</div>
{/if}
{/if}
</main>
</div>
</div>