feat: C3 — Variety review screen (Issue #28) #41

Merged
marcel merged 6 commits from feat/issue-28-variety-review into master 2026-04-03 11:37:53 +02:00
11 changed files with 727 additions and 0 deletions
Showing only changes of commit 8ad636f825 - Show all commits

View File

@@ -0,0 +1,66 @@
<script lang="ts">
let {
easy,
medium,
hard
}: {
easy: number;
medium: number;
hard: number;
} = $props();
let total = $derived(easy + medium + hard);
</script>
<!-- Labels below the bar -->
<div class="space-y-2">
<!-- Bar segments -->
<div class="flex h-[10px] overflow-hidden rounded-full">
{#if easy > 0}
<div
class="h-full bg-[var(--green)]"
style="flex: {easy}"
></div>
{/if}
{#if medium > 0}
<div
class="h-full bg-[var(--yellow)]"
style="flex: {medium}"
></div>
{/if}
{#if hard > 0}
<div
class="h-full bg-[var(--color-error)]"
style="flex: {hard}"
></div>
{/if}
</div>
<!-- Labels -->
<div class="flex gap-4">
{#if easy > 0}
<span
data-testid="effort-easy"
class="font-[var(--font-sans)] text-[12px] text-[var(--green-dark)]"
>
Einfach ×{easy}
</span>
{/if}
{#if medium > 0}
<span
data-testid="effort-medium"
class="font-[var(--font-sans)] text-[12px] text-[var(--yellow-text)]"
>
Mittel ×{medium}
</span>
{/if}
{#if hard > 0}
<span
data-testid="effort-hard"
class="font-[var(--font-sans)] text-[12px] text-[var(--color-error)]"
>
Aufwändig ×{hard}
</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import EffortBar from './EffortBar.svelte';
describe('EffortBar', () => {
it('renders segment for easy effort', () => {
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
expect(screen.getByTestId('effort-easy').textContent).toContain('3');
});
it('renders segment for medium effort', () => {
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
expect(screen.getByTestId('effort-medium').textContent).toContain('3');
});
it('renders segment for hard effort', () => {
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
expect(screen.getByTestId('effort-hard').textContent).toContain('1');
});
it('hides zero-count segments', () => {
render(EffortBar, { props: { easy: 7, medium: 0, hard: 0 } });
expect(screen.queryByTestId('effort-medium')).toBeNull();
expect(screen.queryByTestId('effort-hard')).toBeNull();
});
it('renders label with ×N count', () => {
render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } });
expect(screen.getByTestId('effort-easy').textContent).toContain('×3');
});
});

View File

@@ -0,0 +1,39 @@
<script lang="ts">
interface SubScores {
proteinDiversity: number;
ingredientOverlap: number;
effortBalance: number;
}
let { subScores }: { subScores: SubScores } = $props();
</script>
<ul class="divide-y divide-[var(--color-border)] rounded-[var(--radius-md)] border border-[var(--color-border)]">
<li
data-testid="sub-protein"
class="flex items-center justify-between px-4 py-3"
>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Protein-Vielfalt</span>
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
{subScores.proteinDiversity}/10
</span>
</li>
<li
data-testid="sub-ingredient"
class="flex items-center justify-between px-4 py-3"
>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Zutaten-Überlappung</span>
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
{subScores.ingredientOverlap}/10
</span>
</li>
<li
data-testid="sub-effort"
class="flex items-center justify-between px-4 py-3"
>
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)]">Aufwandsbalance</span>
<span class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--color-text)]">
{subScores.effortBalance}/10
</span>
</li>
</ul>

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ScoreBreakdownList from './ScoreBreakdownList.svelte';
const subScores = {
proteinDiversity: 9,
ingredientOverlap: 7,
effortBalance: 8
};
describe('ScoreBreakdownList', () => {
it('renders protein diversity row', () => {
render(ScoreBreakdownList, { props: { subScores } });
expect(screen.getByTestId('sub-protein').textContent).toContain('9');
});
it('renders ingredient overlap row', () => {
render(ScoreBreakdownList, { props: { subScores } });
expect(screen.getByTestId('sub-ingredient').textContent).toContain('7');
});
it('renders effort balance row', () => {
render(ScoreBreakdownList, { props: { subScores } });
expect(screen.getByTestId('sub-effort').textContent).toContain('8');
});
it('renders all rows with /10 suffix', () => {
render(ScoreBreakdownList, { props: { subScores } });
const items = screen.getAllByTestId(/^sub-/);
expect(items.length).toBe(3);
items.forEach((item) => {
expect(item.textContent).toContain('/10');
});
});
});

View File

@@ -0,0 +1,56 @@
<script lang="ts">
let {
score
}: {
score: number;
} = $props();
let percentage = $derived(Math.round((score / 10) * 100));
let description = $derived(
score >= 9
? { label: 'Ausgezeichnet', colorClass: 'text-[var(--green-dark)]' }
: score >= 7
? { label: 'Gut', colorClass: 'text-[var(--color-text)]' }
: score >= 4
? { label: 'Verbesserbar', colorClass: 'text-[var(--yellow-text)]' }
: { label: 'Unzureichend', colorClass: 'text-[var(--color-error)]' }
);
</script>
<div>
<!-- Score number + out of 10 -->
<div class="flex items-baseline gap-2">
<span
data-testid="score-value"
class="font-[var(--font-display)] text-[56px] font-[300] leading-none text-[var(--color-text)] lg:text-[72px]"
>
{score}
</span>
<span
data-testid="score-label"
class="font-[var(--font-sans)] text-[16px] text-[var(--color-text-muted)]"
>
/ 10
</span>
<span
data-testid="score-description"
class="ml-1 font-[var(--font-sans)] text-[14px] font-medium {description.colorClass}"
>
{description.label}
</span>
</div>
<!-- Progress bar -->
<div class="mt-3 h-[6px] w-[120px] overflow-hidden rounded-full bg-[var(--color-border)] lg:w-[200px]">
<div
role="progressbar"
aria-valuenow={score}
aria-valuemin={0}
aria-valuemax={10}
aria-label="Abwechslungs-Score"
class="h-full rounded-full bg-[var(--yellow)] transition-all"
style="width: {percentage}%"
></div>
</div>
</div>

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import VarietyScoreHero from './VarietyScoreHero.svelte';
describe('VarietyScoreHero', () => {
it('renders the score number', () => {
render(VarietyScoreHero, { props: { score: 8.2 } });
expect(screen.getByTestId('score-value').textContent).toContain('8.2');
});
it('renders "out of 10" label', () => {
render(VarietyScoreHero, { props: { score: 8.2 } });
expect(screen.getByTestId('score-label').textContent).toContain('10');
});
it('renders a progressbar with correct aria attributes', () => {
render(VarietyScoreHero, { props: { score: 8.2 } });
const bar = screen.getByRole('progressbar');
expect(bar.getAttribute('aria-valuenow')).toBe('8.2');
expect(bar.getAttribute('aria-valuemin')).toBe('0');
expect(bar.getAttribute('aria-valuemax')).toBe('10');
});
it('shows "Excellent variety" description for score >= 9', () => {
render(VarietyScoreHero, { props: { score: 9.5 } });
expect(screen.getByTestId('score-description').textContent).toContain('Ausgezeichnet');
});
it('shows "Good variety" description for score 7-8.9', () => {
render(VarietyScoreHero, { props: { score: 7.5 } });
expect(screen.getByTestId('score-description').textContent).toContain('Gut');
});
it('shows "Getting there" description for score 4-6.9', () => {
render(VarietyScoreHero, { props: { score: 5.0 } });
expect(screen.getByTestId('score-description').textContent).toContain('Verbesserbar');
});
it('shows "Needs improvement" description for score < 4', () => {
render(VarietyScoreHero, { props: { score: 2.1 } });
expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend');
});
});

View File

@@ -0,0 +1,22 @@
<script lang="ts">
interface Warning {
title: string;
explanation: string;
}
let { warnings }: { warnings: Warning[] } = $props();
</script>
{#each warnings as warning}
<div
data-testid="warning-card"
class="rounded-[var(--radius-lg)] bg-[var(--yellow-tint)] px-4 py-3"
>
<p class="font-[var(--font-sans)] text-[13px] font-medium text-[var(--yellow-text)]">
{warning.title}
</p>
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
{warning.explanation}
</p>
</div>
{/each}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import VarietyWarningCards from './VarietyWarningCards.svelte';
const warnings = [
{ title: 'Chicken zweimal diese Woche', explanation: 'Mo, Mi — erwäge einen Tausch.' },
{ title: 'Tomaten in 3 Gerichten', explanation: 'Mo, Di, Mi — sorge für Abwechslung.' }
];
describe('VarietyWarningCards', () => {
it('renders one card per warning', () => {
render(VarietyWarningCards, { props: { warnings } });
const cards = screen.getAllByTestId('warning-card');
expect(cards.length).toBe(2);
});
it('renders warning titles', () => {
render(VarietyWarningCards, { props: { warnings } });
expect(screen.getByText(/Chicken zweimal/)).toBeTruthy();
expect(screen.getByText(/Tomaten in 3/)).toBeTruthy();
});
it('renders warning explanations', () => {
render(VarietyWarningCards, { props: { warnings } });
expect(screen.getByText(/erwäge einen Tausch/)).toBeTruthy();
});
it('renders nothing when warnings is empty', () => {
render(VarietyWarningCards, { props: { warnings: [] } });
expect(screen.queryAllByTestId('warning-card').length).toBe(0);
});
});

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

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>

View File

@@ -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}$/);
});
});