From 8ad636f8255cbe4b085ecb2d69596b5972d18978 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:23:29 +0200 Subject: [PATCH 1/6] feat(variety): implement C3 variety review screen (Issue #28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/lib/planner/EffortBar.svelte | 66 +++++ frontend/src/lib/planner/EffortBar.test.ts | 31 ++ .../src/lib/planner/ScoreBreakdownList.svelte | 39 +++ .../lib/planner/ScoreBreakdownList.test.ts | 35 +++ .../src/lib/planner/VarietyScoreHero.svelte | 56 ++++ .../src/lib/planner/VarietyScoreHero.test.ts | 43 +++ .../lib/planner/VarietyWarningCards.svelte | 22 ++ .../lib/planner/VarietyWarningCards.test.ts | 32 ++ .../(app)/planner/variety/+page.server.ts | 28 ++ .../routes/(app)/planner/variety/+page.svelte | 277 ++++++++++++++++++ .../(app)/planner/variety/page.server.test.ts | 98 +++++++ 11 files changed, 727 insertions(+) create mode 100644 frontend/src/lib/planner/EffortBar.svelte create mode 100644 frontend/src/lib/planner/EffortBar.test.ts create mode 100644 frontend/src/lib/planner/ScoreBreakdownList.svelte create mode 100644 frontend/src/lib/planner/ScoreBreakdownList.test.ts create mode 100644 frontend/src/lib/planner/VarietyScoreHero.svelte create mode 100644 frontend/src/lib/planner/VarietyScoreHero.test.ts create mode 100644 frontend/src/lib/planner/VarietyWarningCards.svelte create mode 100644 frontend/src/lib/planner/VarietyWarningCards.test.ts create mode 100644 frontend/src/routes/(app)/planner/variety/+page.server.ts create mode 100644 frontend/src/routes/(app)/planner/variety/+page.svelte create mode 100644 frontend/src/routes/(app)/planner/variety/page.server.test.ts diff --git a/frontend/src/lib/planner/EffortBar.svelte b/frontend/src/lib/planner/EffortBar.svelte new file mode 100644 index 0000000..10d9f07 --- /dev/null +++ b/frontend/src/lib/planner/EffortBar.svelte @@ -0,0 +1,66 @@ + + + +
+ +
+ {#if easy > 0} +
+ {/if} + {#if medium > 0} +
+ {/if} + {#if hard > 0} +
+ {/if} +
+ + +
+ {#if easy > 0} + + Einfach ×{easy} + + {/if} + {#if medium > 0} + + Mittel ×{medium} + + {/if} + {#if hard > 0} + + Aufwändig ×{hard} + + {/if} +
+
diff --git a/frontend/src/lib/planner/EffortBar.test.ts b/frontend/src/lib/planner/EffortBar.test.ts new file mode 100644 index 0000000..f08302f --- /dev/null +++ b/frontend/src/lib/planner/EffortBar.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/planner/ScoreBreakdownList.svelte b/frontend/src/lib/planner/ScoreBreakdownList.svelte new file mode 100644 index 0000000..3e4f98f --- /dev/null +++ b/frontend/src/lib/planner/ScoreBreakdownList.svelte @@ -0,0 +1,39 @@ + + + diff --git a/frontend/src/lib/planner/ScoreBreakdownList.test.ts b/frontend/src/lib/planner/ScoreBreakdownList.test.ts new file mode 100644 index 0000000..b5dc51e --- /dev/null +++ b/frontend/src/lib/planner/ScoreBreakdownList.test.ts @@ -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'); + }); + }); +}); diff --git a/frontend/src/lib/planner/VarietyScoreHero.svelte b/frontend/src/lib/planner/VarietyScoreHero.svelte new file mode 100644 index 0000000..2d758e6 --- /dev/null +++ b/frontend/src/lib/planner/VarietyScoreHero.svelte @@ -0,0 +1,56 @@ + + +
+ +
+ + {score} + + + / 10 + + + {description.label} + +
+ + +
+
+
+
diff --git a/frontend/src/lib/planner/VarietyScoreHero.test.ts b/frontend/src/lib/planner/VarietyScoreHero.test.ts new file mode 100644 index 0000000..16a69ae --- /dev/null +++ b/frontend/src/lib/planner/VarietyScoreHero.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/planner/VarietyWarningCards.svelte b/frontend/src/lib/planner/VarietyWarningCards.svelte new file mode 100644 index 0000000..977252c --- /dev/null +++ b/frontend/src/lib/planner/VarietyWarningCards.svelte @@ -0,0 +1,22 @@ + + +{#each warnings as warning} +
+

+ {warning.title} +

+

+ {warning.explanation} +

+
+{/each} diff --git a/frontend/src/lib/planner/VarietyWarningCards.test.ts b/frontend/src/lib/planner/VarietyWarningCards.test.ts new file mode 100644 index 0000000..4ae479e --- /dev/null +++ b/frontend/src/lib/planner/VarietyWarningCards.test.ts @@ -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); + }); +}); diff --git a/frontend/src/routes/(app)/planner/variety/+page.server.ts b/frontend/src/routes/(app)/planner/variety/+page.server.ts new file mode 100644 index 0000000..e511637 --- /dev/null +++ b/frontend/src/routes/(app)/planner/variety/+page.server.ts @@ -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 + }; +}; diff --git a/frontend/src/routes/(app)/planner/variety/+page.svelte b/frontend/src/routes/(app)/planner/variety/+page.svelte new file mode 100644 index 0000000..3a3b2f6 --- /dev/null +++ b/frontend/src/routes/(app)/planner/variety/+page.svelte @@ -0,0 +1,277 @@ + + + + Abwechslung überprüfen — Mealplan + + + +
+ +
+ + ‹ + +

+ Abwechslungs-Analyse +

+
+ +
+ {#if !varietyScore} +
+

+ Noch keine Gerichte geplant. Plane zuerst einige Mahlzeiten. +

+ + Zum Wochenplaner → + +
+ {:else} + +
+ +
+ + +
+

+ Bewertung im Detail +

+ +
+ + + {#if warnings().length > 0} +
+

+ Hinweise +

+ +
+ {/if} + {/if} +
+
+ + + diff --git a/frontend/src/routes/(app)/planner/variety/page.server.test.ts b/frontend/src/routes/(app)/planner/variety/page.server.test.ts new file mode 100644 index 0000000..6eea40d --- /dev/null +++ b/frontend/src/routes/(app)/planner/variety/page.server.test.ts @@ -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}$/); + }); +}); From 75c860a62b12541b659647e961911e20b9b6a4d5 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:29:26 +0200 Subject: [PATCH 2/6] test(variety): add boundary tests for VarietyScoreHero (score=0,4,7,10) Addresses QA concern: boundary values (0, 4, 7, 9, 10) now have explicit tests covering description labels and aria-valuenow. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/VarietyScoreHero.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/src/lib/planner/VarietyScoreHero.test.ts b/frontend/src/lib/planner/VarietyScoreHero.test.ts index 16a69ae..a781f65 100644 --- a/frontend/src/lib/planner/VarietyScoreHero.test.ts +++ b/frontend/src/lib/planner/VarietyScoreHero.test.ts @@ -40,4 +40,35 @@ describe('VarietyScoreHero', () => { render(VarietyScoreHero, { props: { score: 2.1 } }); expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend'); }); + + it('shows "Unzureichend" for score = 0 (boundary)', () => { + render(VarietyScoreHero, { props: { score: 0 } }); + expect(screen.getByTestId('score-description').textContent).toContain('Unzureichend'); + }); + + it('renders score 0 in score-value for score = 0', () => { + render(VarietyScoreHero, { props: { score: 0 } }); + expect(screen.getByTestId('score-value').textContent).toContain('0'); + }); + + it('renders 0-width progress bar for score = 0', () => { + render(VarietyScoreHero, { props: { score: 0 } }); + const bar = screen.getByRole('progressbar'); + expect(bar.getAttribute('aria-valuenow')).toBe('0'); + }); + + it('shows "Ausgezeichnet" for score = 10 (boundary)', () => { + render(VarietyScoreHero, { props: { score: 10 } }); + expect(screen.getByTestId('score-description').textContent).toContain('Ausgezeichnet'); + }); + + it('shows "Verbesserbar" for score = 4 (boundary)', () => { + render(VarietyScoreHero, { props: { score: 4 } }); + expect(screen.getByTestId('score-description').textContent).toContain('Verbesserbar'); + }); + + it('shows "Gut" for score = 7 (boundary)', () => { + render(VarietyScoreHero, { props: { score: 7 } }); + expect(screen.getByTestId('score-description').textContent).toContain('Gut'); + }); }); From 1bf929280b3be905698a8be0721ffbcd2181be0c Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:30:19 +0200 Subject: [PATCH 3/6] test(variety): add all-zero edge case test for EffortBar Addresses QA concern: renders no segments when easy=0, medium=0, hard=0. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/EffortBar.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/lib/planner/EffortBar.test.ts b/frontend/src/lib/planner/EffortBar.test.ts index f08302f..00e951d 100644 --- a/frontend/src/lib/planner/EffortBar.test.ts +++ b/frontend/src/lib/planner/EffortBar.test.ts @@ -28,4 +28,11 @@ describe('EffortBar', () => { render(EffortBar, { props: { easy: 3, medium: 3, hard: 1 } }); expect(screen.getByTestId('effort-easy').textContent).toContain('×3'); }); + + it('renders no segments when all counts are zero', () => { + render(EffortBar, { props: { easy: 0, medium: 0, hard: 0 } }); + expect(screen.queryByTestId('effort-easy')).toBeNull(); + expect(screen.queryByTestId('effort-medium')).toBeNull(); + expect(screen.queryByTestId('effort-hard')).toBeNull(); + }); }); From 9adf786b8f98508db9240360b161b26abee5e5e7 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:32:20 +0200 Subject: [PATCH 4/6] test(variety): extract and test sub-score/warnings pure functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract computeSubScores() and computeWarnings() to variety.ts - 18 unit tests covering formulas, boundaries, clamping, edge cases: - proteinDiversity: repeats × 2 penalty, clamped to 0 - ingredientOverlap: overlaps × 1.5 penalty, clamped to 0 - effortBalance: easy-hard diff × 1.5, total=0 → 10 - warnings: repeat≥2 days, overlap≥2 days, duplicates Addresses QA blockers: untested business logic in sub-score derivations. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/variety.test.ts | 123 +++++++++++++++++++++++ frontend/src/lib/planner/variety.ts | 88 ++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 frontend/src/lib/planner/variety.test.ts create mode 100644 frontend/src/lib/planner/variety.ts diff --git a/frontend/src/lib/planner/variety.test.ts b/frontend/src/lib/planner/variety.test.ts new file mode 100644 index 0000000..7b9d1f8 --- /dev/null +++ b/frontend/src/lib/planner/variety.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { computeSubScores, computeWarnings } from './variety'; + +describe('computeSubScores', () => { + it('returns proteinDiversity=10 when no protein repeats', () => { + const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 4, medium: 2, hard: 1 }); + expect(result.proteinDiversity).toBe(10); + }); + + it('reduces proteinDiversity by 2 per protein repeat', () => { + const tagRepeats = [ + { tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }, + { tagType: 'protein', tagName: 'Beef', days: ['WED', 'THU'] } + ]; + const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 }); + // 2 protein repeat entries → 10 - 2*2 = 6 + expect(result.proteinDiversity).toBe(6); + }); + + it('clamps proteinDiversity to minimum 0', () => { + const tagRepeats = Array.from({ length: 6 }, (_, i) => ({ + tagType: 'protein', tagName: `P${i}`, days: ['MON', 'TUE'] + })); + const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 }); + expect(result.proteinDiversity).toBe(0); + }); + + it('returns ingredientOverlap=10 when no overlaps', () => { + const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 }); + expect(result.ingredientOverlap).toBe(10); + }); + + it('reduces ingredientOverlap by 1.5 per overlap (rounded)', () => { + const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'TUE'] }]; + const result = computeSubScores({ tagRepeats: [], ingredientOverlaps, easy: 0, medium: 0, hard: 0 }); + // 1 overlap → 10 - 1*1.5 = 8.5 → round = 9 (Math.round rounds .5 up) + expect(result.ingredientOverlap).toBe(9); + }); + + it('clamps ingredientOverlap to minimum 0', () => { + const ingredientOverlaps = Array.from({ length: 8 }, (_, i) => ({ + ingredientName: `Ing${i}`, days: ['MON', 'TUE'] + })); + const result = computeSubScores({ tagRepeats: [], ingredientOverlaps, easy: 0, medium: 0, hard: 0 }); + expect(result.ingredientOverlap).toBe(0); + }); + + it('returns effortBalance=10 when no meals (total=0)', () => { + const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 }); + expect(result.effortBalance).toBe(10); + }); + + it('returns effortBalance=10 when easy and hard are equal', () => { + const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 3, medium: 0, hard: 3 }); + // |3-3| = 0 → 10 - 0 = 10 + expect(result.effortBalance).toBe(10); + }); + + it('reduces effortBalance by 1.5 per unit of easy-hard difference', () => { + const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 4, medium: 0, hard: 0 }); + // |4-0| = 4 → 10 - 4*1.5 = 4 → round(4) = 4 + expect(result.effortBalance).toBe(4); + }); + + it('clamps effortBalance to minimum 0', () => { + const result = computeSubScores({ tagRepeats: [], ingredientOverlaps: [], easy: 10, medium: 0, hard: 0 }); + // |10-0| = 10 → 10 - 10*1.5 = -5 → clamp to 0 + expect(result.effortBalance).toBe(0); + }); + + it('ignores non-protein tag repeats for proteinDiversity', () => { + const tagRepeats = [{ tagType: 'category', tagName: 'Pasta', days: ['MON', 'TUE'] }]; + const result = computeSubScores({ tagRepeats, ingredientOverlaps: [], easy: 0, medium: 0, hard: 0 }); + expect(result.proteinDiversity).toBe(10); + }); +}); + +describe('computeWarnings', () => { + it('returns empty array when no repeats or overlaps', () => { + const result = computeWarnings({ tagRepeats: [], ingredientOverlaps: [], duplicatesInPlan: [] }); + expect(result).toHaveLength(0); + }); + + it('generates warning for protein appearing on 2+ days', () => { + const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }]; + const result = computeWarnings({ tagRepeats, ingredientOverlaps: [], duplicatesInPlan: [] }); + expect(result).toHaveLength(1); + expect(result[0].title).toContain('Chicken'); + }); + + it('does not generate warning for protein appearing on only 1 day', () => { + const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON'] }]; + const result = computeWarnings({ tagRepeats, ingredientOverlaps: [], duplicatesInPlan: [] }); + expect(result).toHaveLength(0); + }); + + it('generates warning for ingredient overlap on 2+ days', () => { + const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'WED'] }]; + const result = computeWarnings({ tagRepeats: [], ingredientOverlaps, duplicatesInPlan: [] }); + expect(result).toHaveLength(1); + expect(result[0].title).toContain('Rice'); + }); + + it('does not generate warning for ingredient appearing on only 1 day', () => { + const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON'] }]; + const result = computeWarnings({ tagRepeats: [], ingredientOverlaps, duplicatesInPlan: [] }); + expect(result).toHaveLength(0); + }); + + it('generates warning for each duplicate recipe in plan', () => { + const result = computeWarnings({ tagRepeats: [], ingredientOverlaps: [], duplicatesInPlan: ['Pasta Bolognese', 'Risotto'] }); + expect(result).toHaveLength(2); + expect(result[0].title).toContain('Pasta Bolognese'); + expect(result[1].title).toContain('Risotto'); + }); + + it('combines all warning types', () => { + const tagRepeats = [{ tagType: 'protein', tagName: 'Chicken', days: ['MON', 'TUE'] }]; + const ingredientOverlaps = [{ ingredientName: 'Rice', days: ['MON', 'WED'] }]; + const result = computeWarnings({ tagRepeats, ingredientOverlaps, duplicatesInPlan: ['Pasta'] }); + expect(result).toHaveLength(3); + }); +}); diff --git a/frontend/src/lib/planner/variety.ts b/frontend/src/lib/planner/variety.ts new file mode 100644 index 0000000..8edbf9f --- /dev/null +++ b/frontend/src/lib/planner/variety.ts @@ -0,0 +1,88 @@ +interface TagRepeat { + tagType?: string; + tagName?: string; + days?: string[]; +} + +interface IngredientOverlap { + ingredientName?: string; + days?: string[]; +} + +interface SubScoreInput { + tagRepeats: TagRepeat[]; + ingredientOverlaps: IngredientOverlap[]; + easy: number; + medium: number; + hard: number; +} + +export interface SubScores { + proteinDiversity: number; + ingredientOverlap: number; + effortBalance: number; +} + +export function computeSubScores(input: SubScoreInput): SubScores { + const { tagRepeats, ingredientOverlaps, easy, medium, hard } = input; + + const proteinRepeats = tagRepeats.filter((t) => t.tagType === 'protein').length; + const ingredientOverlapCount = ingredientOverlaps.length; + const total = easy + medium + hard; + + const effortBalance = + total === 0 + ? 10 + : Math.min(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 + }; +} + +interface WarningInput { + tagRepeats: TagRepeat[]; + ingredientOverlaps: IngredientOverlap[]; + duplicatesInPlan: string[]; +} + +export interface Warning { + title: string; + explanation: string; +} + +export function computeWarnings(input: WarningInput): Warning[] { + const { tagRepeats, ingredientOverlaps, duplicatesInPlan } = input; + const result: Warning[] = []; + + for (const repeat of tagRepeats) { + if ((repeat.days?.length ?? 0) > 1) { + const days = (repeat.days ?? []).join(', '); + result.push({ + title: `${repeat.tagName} mehrfach diese Woche`, + explanation: `${days} — erwäge einen Tausch für mehr Protein-Abwechslung.` + }); + } + } + + for (const overlap of 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.` + }); + } + } + + for (const name of duplicatesInPlan) { + result.push({ + title: `${name} doppelt geplant`, + explanation: 'Dasselbe Rezept erscheint mehrfach — tausche eines aus.' + }); + } + + return result; +} From cb15143c30fc8f10827f25f85723cd19c2c9a827 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:36:00 +0200 Subject: [PATCH 5/6] refactor(variety): fix \$derived.by pattern, remove dead import, use pure functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change all \$derived(() => {...}) to \$derived.by(() => {...}) — values not functions - Remove unused formatDayLabel import - Delegate subScores to computeSubScores(), warnings to computeWarnings() - Remove () call syntax from all template reactive references Addresses Kai blockers: anti-pattern derived, dead import. Addresses QA blocker: logic now exercised by unit tests in variety.ts. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/(app)/planner/variety/+page.svelte | 96 +++++-------------- 1 file changed, 26 insertions(+), 70 deletions(-) diff --git a/frontend/src/routes/(app)/planner/variety/+page.svelte b/frontend/src/routes/(app)/planner/variety/+page.svelte index 3a3b2f6..1950f57 100644 --- a/frontend/src/routes/(app)/planner/variety/+page.svelte +++ b/frontend/src/routes/(app)/planner/variety/+page.svelte @@ -3,7 +3,7 @@ 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'; + import { computeSubScores, computeWarnings } from '$lib/planner/variety'; let { data } = $props(); @@ -14,7 +14,7 @@ let score = $derived(varietyScore?.score ?? 0); // Derive effort distribution from week plan slots - let effortCounts = $derived(() => { + let effortCounts = $derived.by(() => { const slots = weekPlan?.slots ?? []; let easy = 0, medium = 0, hard = 0; for (const slot of slots) { @@ -28,65 +28,21 @@ // 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) - }; - }); + let subScores = $derived.by(() => computeSubScores({ + tagRepeats: varietyScore?.tagRepeats ?? [], + ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [], + ...effortCounts + })); // 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; - }); + let warnings = $derived.by(() => computeWarnings({ + tagRepeats: varietyScore?.tagRepeats ?? [], + ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [], + duplicatesInPlan: varietyScore?.duplicatesInPlan ?? [] + })); // Protein grid: map protein tags to days of the week - let proteinByDay = $derived(() => { + let proteinByDay = $derived.by(() => { const map: Record = {}; for (const repeat of varietyScore?.tagRepeats ?? []) { if (repeat.tagType === 'protein') { @@ -147,16 +103,16 @@

Bewertung im Detail

- + - {#if warnings().length > 0} + {#if warnings.length > 0}

Hinweise

- +
{/if} {/if} @@ -208,7 +164,7 @@

Bewertung im Detail

- + @@ -220,10 +176,10 @@ Protein-Verteilung
- {#each weekDayAbbrs as abbr, i} + {#each weekDayAbbrs as abbr, i (weekDayKeys[i])} {@const key = weekDayKeys[i]} - {@const protein = proteinByDay()[key]} - {@const isRepeated = protein && Object.values(proteinByDay()).filter((p) => p === protein).length > 1} + {@const protein = proteinByDay[key]} + {@const isRepeated = protein && Object.values(proteinByDay).filter((p) => p === protein).length > 1}
{abbr}
Aufwandsverteilung - {#if (effortCounts().easy + effortCounts().medium + effortCounts().hard) > 0} + {#if (effortCounts.easy + effortCounts.medium + effortCounts.hard) > 0} {:else}

@@ -263,12 +219,12 @@

- {#if warnings().length > 0} + {#if warnings.length > 0}

Hinweise

- +
{/if} {/if} From 8e82213d1ee2dc6266e94696cb5ce4bd822a4426 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:37:26 +0200 Subject: [PATCH 6/6] fix(variety): remove unused total, add warning border, fix abbreviation, aria - EffortBar: remove unused \`total\` derived variable - VarietyWarningCards: add border border-[var(--yellow-light)] to cards - variety page: protein abbreviation uses split(' ')[0].slice(0,3).toUpperCase() - variety page: breadcrumb separator span gets aria-hidden="true" Addresses Kai blocker: unused total. Atlas blockers: yellow-light border, protein abbreviation, breadcrumb aria. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/EffortBar.svelte | 2 +- frontend/src/lib/planner/VarietyWarningCards.svelte | 2 +- frontend/src/routes/(app)/planner/variety/+page.svelte | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/planner/EffortBar.svelte b/frontend/src/lib/planner/EffortBar.svelte index 10d9f07..611906c 100644 --- a/frontend/src/lib/planner/EffortBar.svelte +++ b/frontend/src/lib/planner/EffortBar.svelte @@ -9,7 +9,7 @@ hard: number; } = $props(); - let total = $derived(easy + medium + hard); + diff --git a/frontend/src/lib/planner/VarietyWarningCards.svelte b/frontend/src/lib/planner/VarietyWarningCards.svelte index 977252c..dcf0794 100644 --- a/frontend/src/lib/planner/VarietyWarningCards.svelte +++ b/frontend/src/lib/planner/VarietyWarningCards.svelte @@ -10,7 +10,7 @@ {#each warnings as warning}

{warning.title} diff --git a/frontend/src/routes/(app)/planner/variety/+page.svelte b/frontend/src/routes/(app)/planner/variety/+page.svelte index 1950f57..9b285b6 100644 --- a/frontend/src/routes/(app)/planner/variety/+page.svelte +++ b/frontend/src/routes/(app)/planner/variety/+page.svelte @@ -129,7 +129,7 @@ > Planer - / +

Abwechslungs-Analyse

@@ -191,7 +191,7 @@ : 'bg-[var(--color-subtle)] text-[var(--color-text-muted)]'} {isRepeated ? 'ring-2 ring-[var(--yellow)]' : ''}" > - {protein ? protein.slice(0, 3) : '—'} + {protein ? protein.split(' ')[0].slice(0, 3).toUpperCase() : '—'}
{/each}