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:
28
frontend/src/routes/(app)/planner/variety/+page.server.ts
Normal file
28
frontend/src/routes/(app)/planner/variety/+page.server.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url }) => {
|
||||
const weekParam = url.searchParams.get('week');
|
||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||
|
||||
const api = apiClient(fetch);
|
||||
|
||||
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
});
|
||||
|
||||
if (weekPlanError || !weekPlan?.id) {
|
||||
return { weekPlan: null, varietyScore: null, weekStart };
|
||||
}
|
||||
|
||||
const { data: varietyScore } = await api.GET('/v1/week-plans/{id}/variety-score', {
|
||||
params: { path: { id: weekPlan.id } }
|
||||
});
|
||||
|
||||
return {
|
||||
weekPlan,
|
||||
varietyScore: varietyScore ?? null,
|
||||
weekStart
|
||||
};
|
||||
};
|
||||
277
frontend/src/routes/(app)/planner/variety/+page.svelte
Normal file
277
frontend/src/routes/(app)/planner/variety/+page.svelte
Normal 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>
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet })
|
||||
}));
|
||||
|
||||
const mockVarietyScore = {
|
||||
score: 8.2,
|
||||
tagRepeats: [
|
||||
{ tagName: 'Chicken', tagType: 'protein', days: ['MON', 'WED'] }
|
||||
],
|
||||
ingredientOverlaps: [
|
||||
{ ingredientName: 'Tomaten', days: ['MON', 'TUE', 'WED'] }
|
||||
],
|
||||
recentRepeats: ['Pasta Bolognese'],
|
||||
duplicatesInPlan: ['Hühnchen Curry']
|
||||
};
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy', cookTimeMin: 20 } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } },
|
||||
{ id: 's3', slotDate: '2026-04-01', recipe: { id: 'r3', name: 'Steak', effort: 'Hard', cookTimeMin: 60 } }
|
||||
]
|
||||
};
|
||||
|
||||
describe('variety page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
it('fetches week plan and variety score', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Planer', rolle: 'planer' } } });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.anything());
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/variety-score', expect.objectContaining({
|
||||
params: { path: { id: 'plan-1' } }
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns varietyScore and weekPlan in result', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Planer', rolle: 'planer' } } });
|
||||
expect(result.varietyScore?.score).toBe(8.2);
|
||||
expect(result.weekPlan?.id).toBe('plan-1');
|
||||
});
|
||||
|
||||
it('returns weekStart from URL param', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||
expect(result.weekStart).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns null data when week plan not found', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null varietyScore when score endpoint fails', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||
const url = new URL('http://localhost/planner/variety?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||
expect(result.weekPlan?.id).toBe('plan-1');
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
|
||||
it('uses current week when no week param provided', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockVarietyScore, error: undefined });
|
||||
const url = new URL('http://localhost/planner/variety');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: {} });
|
||||
// weekStart should be a valid YYYY-MM-DD
|
||||
expect(result.weekStart).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user