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:
66
frontend/src/lib/planner/EffortBar.svelte
Normal file
66
frontend/src/lib/planner/EffortBar.svelte
Normal 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>
|
||||
31
frontend/src/lib/planner/EffortBar.test.ts
Normal file
31
frontend/src/lib/planner/EffortBar.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
39
frontend/src/lib/planner/ScoreBreakdownList.svelte
Normal file
39
frontend/src/lib/planner/ScoreBreakdownList.svelte
Normal 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>
|
||||
35
frontend/src/lib/planner/ScoreBreakdownList.test.ts
Normal file
35
frontend/src/lib/planner/ScoreBreakdownList.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
56
frontend/src/lib/planner/VarietyScoreHero.svelte
Normal file
56
frontend/src/lib/planner/VarietyScoreHero.svelte
Normal 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>
|
||||
43
frontend/src/lib/planner/VarietyScoreHero.test.ts
Normal file
43
frontend/src/lib/planner/VarietyScoreHero.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
22
frontend/src/lib/planner/VarietyWarningCards.svelte
Normal file
22
frontend/src/lib/planner/VarietyWarningCards.svelte
Normal 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}
|
||||
32
frontend/src/lib/planner/VarietyWarningCards.test.ts
Normal file
32
frontend/src/lib/planner/VarietyWarningCards.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user