Compare commits
33 Commits
feat/issue
...
6d76da5542
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d76da5542 | |||
| 8e82213d1e | |||
| cb15143c30 | |||
| 9adf786b8f | |||
| 1bf929280b | |||
| 75c860a62b | |||
| 8ad636f825 | |||
| 7c07bc443b | |||
| 05e47c3dac | |||
| 5d2bb9e84e | |||
| e3f8d8ad73 | |||
| 0511a735a5 | |||
| 33f3b30cb4 | |||
| e4d3008139 | |||
| 6505cb4251 | |||
| 3d49e6b7bf | |||
| 4e2b0b5727 | |||
| 2cef8a1169 | |||
| fcf0f297bb | |||
| 0256b4360b | |||
| 00c48a7c96 | |||
| ce860d68e4 | |||
| b39d04acce | |||
| c7e56a173d | |||
| 86a25eb038 | |||
| a34c6f30f2 | |||
| 9bb6293d9f | |||
| 47c748145d | |||
| a25286e385 | |||
| a733e8dd66 | |||
| 35ed6ca878 | |||
| dc99459a2e | |||
| 021d308a71 |
86
frontend/src/lib/planner/DayMealCard.svelte
Normal file
86
frontend/src/lib/planner/DayMealCard.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe?: SlotRecipe | null;
|
||||
}
|
||||
|
||||
let {
|
||||
slot,
|
||||
isToday = false,
|
||||
isSelected = false,
|
||||
readonly = false
|
||||
}: {
|
||||
slot: Slot;
|
||||
isToday?: boolean;
|
||||
isSelected?: boolean;
|
||||
readonly?: boolean;
|
||||
} = $props();
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
slot.recipe?.cookTimeMin != null ? `${slot.recipe.cookTimeMin} Min` : null,
|
||||
slot.recipe?.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
|
||||
let borderClass = $derived(
|
||||
isToday
|
||||
? 'border-[var(--yellow)] bg-[var(--yellow-tint)]'
|
||||
: isSelected
|
||||
? 'border-[var(--green)] bg-[var(--green-tint)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="day-meal-card"
|
||||
data-today={isToday}
|
||||
data-selected={isSelected}
|
||||
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors {borderClass}"
|
||||
>
|
||||
{#if slot.recipe}
|
||||
<h3 class="font-[var(--font-display)] text-[20px] font-[300] leading-tight text-[var(--color-text)]">
|
||||
{slot.recipe.name}
|
||||
</h3>
|
||||
{#if metadata}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
|
||||
{#if !readonly}
|
||||
<div class="mt-3 flex gap-2">
|
||||
<a
|
||||
href="/recipes/{slot.recipe.id}/cook"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Jetzt kochen
|
||||
</a>
|
||||
<a
|
||||
href="/planner/suggestions?day={slot.slotDate}"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)]"
|
||||
>
|
||||
Tauschen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if !readonly}
|
||||
<a
|
||||
href="/planner/suggestions?day={slot.slotDate}"
|
||||
class="mt-2 inline-block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
63
frontend/src/lib/planner/DayMealCard.test.ts
Normal file
63
frontend/src/lib/planner/DayMealCard.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import DayMealCard from './DayMealCard.svelte';
|
||||
|
||||
const slot = {
|
||||
id: 's1',
|
||||
slotDate: '2026-03-30',
|
||||
recipe: { id: 'r1', name: 'Pasta Bolognese', effort: 'Easy', cookTimeMin: 30 }
|
||||
};
|
||||
|
||||
describe('DayMealCard', () => {
|
||||
it('renders recipe name', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.getByText('Pasta Bolognese')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Cook now and Tauschen links when not readonly', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
|
||||
expect(screen.getByRole('link', { name: /Tauschen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Tauschen link navigates to suggestions for the slot day', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
const link = screen.getByRole('link', { name: /Tauschen/i });
|
||||
expect(link.getAttribute('href')).toContain('2026-03-30');
|
||||
});
|
||||
|
||||
it('hides action links when readonly', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: true } });
|
||||
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
|
||||
expect(screen.queryByRole('link', { name: /Tauschen/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('applies today styling when isToday is true', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: true, readonly: false } });
|
||||
const card = screen.getByTestId('day-meal-card');
|
||||
expect(card.getAttribute('data-today')).toBe('true');
|
||||
});
|
||||
|
||||
it('applies selected styling when isSelected is true and not today', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, isSelected: true, readonly: false } });
|
||||
const card = screen.getByTestId('day-meal-card');
|
||||
expect(card.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders empty state when slot has no recipe', () => {
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||
expect(screen.getByText(/Kein Gericht/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows cook time and effort metadata', () => {
|
||||
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
|
||||
expect(screen.getByText(/30 Min/)).toBeTruthy();
|
||||
expect(screen.getByText(/Easy/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('empty state shows add link with suggestions href', () => {
|
||||
render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false } });
|
||||
const link = screen.getByRole('link', { name: /Gericht hinzufügen/i });
|
||||
expect(link.getAttribute('href')).toContain('2026-03-31');
|
||||
});
|
||||
});
|
||||
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();
|
||||
|
||||
|
||||
</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>
|
||||
38
frontend/src/lib/planner/EffortBar.test.ts
Normal file
38
frontend/src/lib/planner/EffortBar.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
83
frontend/src/lib/planner/SuggestionCard.svelte
Normal file
83
frontend/src/lib/planner/SuggestionCard.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
cookTimeMin?: number;
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
recipe?: SlotRecipe;
|
||||
simulatedScore?: number;
|
||||
reasoningType?: 'good' | 'warning';
|
||||
reasoningLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
suggestion,
|
||||
rank,
|
||||
planId,
|
||||
slotDate,
|
||||
weekStart
|
||||
}: {
|
||||
suggestion: Suggestion;
|
||||
rank: number;
|
||||
planId: string;
|
||||
slotDate: string;
|
||||
weekStart: string;
|
||||
} = $props();
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
suggestion.recipe?.cookTimeMin != null ? `${suggestion.recipe.cookTimeMin} Min` : null,
|
||||
suggestion.recipe?.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex items-start gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 shadow-[var(--shadow-card)]">
|
||||
<!-- Rank number -->
|
||||
<div class="w-10 flex-shrink-0 self-start text-right">
|
||||
<span class="font-[var(--font-display)] text-[32px] font-[300] leading-none text-[var(--color-text-muted)]">{rank}</span>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-[var(--font-sans)] text-[15px] font-medium text-[var(--color-text)] line-clamp-2">
|
||||
{suggestion.recipe?.name ?? 'Unbekanntes Rezept'}
|
||||
</p>
|
||||
{#if metadata}
|
||||
<p class="mt-0.5 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Reasoning badge -->
|
||||
{#if suggestion.reasoningType && suggestion.reasoningLabel}
|
||||
<div
|
||||
data-testid="reasoning-badge"
|
||||
data-type={suggestion.reasoningType}
|
||||
class="mt-2 inline-flex items-center rounded-full px-2 py-0.5 font-[var(--font-sans)] text-[11px] font-medium
|
||||
{suggestion.reasoningType === 'good'
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)]'
|
||||
: 'bg-[var(--yellow-tint)] text-[var(--yellow-text)]'}"
|
||||
>
|
||||
{suggestion.reasoningType === 'good' ? '✓' : '⚠'} {suggestion.reasoningLabel}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pick action -->
|
||||
<form method="POST" action="?/pickSuggestion" class="flex-shrink-0">
|
||||
<input type="hidden" name="planId" value={planId} />
|
||||
<input type="hidden" name="recipeId" value={suggestion.recipe?.id} />
|
||||
<input type="hidden" name="slotDate" value={slotDate} />
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button
|
||||
type="submit"
|
||||
class="font-[var(--font-sans)] text-[13px] font-medium tracking-[0.04em] text-[var(--green-dark)] hover:underline"
|
||||
>
|
||||
Wählen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
60
frontend/src/lib/planner/SuggestionCard.test.ts
Normal file
60
frontend/src/lib/planner/SuggestionCard.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import SuggestionCard from './SuggestionCard.svelte';
|
||||
|
||||
const goodSuggestion = {
|
||||
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
|
||||
simulatedScore: 9.2,
|
||||
reasoningType: 'good' as const,
|
||||
reasoningLabel: 'Frisches Protein · Aufwandsbalance'
|
||||
};
|
||||
|
||||
const warningSuggestion = {
|
||||
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
|
||||
simulatedScore: 6.1,
|
||||
reasoningType: 'warning' as const,
|
||||
reasoningLabel: 'Hähnchen schon 2 Tage dabei'
|
||||
};
|
||||
|
||||
describe('SuggestionCard', () => {
|
||||
it('renders recipe name', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders rank number', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders cook time and effort metadata', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText(/25 Min/)).toBeTruthy();
|
||||
expect(screen.getByText(/Easy/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders green reasoning badge for good suggestions', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
const badge = screen.getByTestId('reasoning-badge');
|
||||
expect(badge.getAttribute('data-type')).toBe('good');
|
||||
expect(badge.textContent).toContain('Frisches Protein');
|
||||
});
|
||||
|
||||
it('renders yellow reasoning badge for warnings', () => {
|
||||
render(SuggestionCard, { props: { suggestion: warningSuggestion, rank: 2, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
const badge = screen.getByTestId('reasoning-badge');
|
||||
expect(badge.getAttribute('data-type')).toBe('warning');
|
||||
expect(badge.textContent).toContain('Hähnchen');
|
||||
});
|
||||
|
||||
it('renders a pick button/form', () => {
|
||||
render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByRole('button', { name: /Wählen/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('card without reasoning renders without crashing', () => {
|
||||
const noReasoning = { ...goodSuggestion, reasoningType: undefined, reasoningLabel: undefined };
|
||||
render(SuggestionCard, { props: { suggestion: noReasoning, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } });
|
||||
expect(screen.getByText('Pasta al Limone')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
86
frontend/src/lib/planner/SuggestionContextBanner.svelte
Normal file
86
frontend/src/lib/planner/SuggestionContextBanner.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { formatDayLabel } from './week';
|
||||
|
||||
interface SlotRecipe {
|
||||
id?: string;
|
||||
name?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe?: SlotRecipe | null;
|
||||
}
|
||||
|
||||
interface WeekPlan {
|
||||
id?: string;
|
||||
weekStart?: string;
|
||||
slots?: Slot[];
|
||||
}
|
||||
|
||||
let {
|
||||
selectedDay,
|
||||
weekPlan
|
||||
}: {
|
||||
selectedDay: string;
|
||||
weekPlan: WeekPlan | null;
|
||||
} = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
let slotsWithMeal = $derived(
|
||||
(weekPlan?.slots ?? []).filter((s) => s.recipe && s.slotDate !== selectedDay)
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="context-banner"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--green-light)] bg-[var(--green-tint)] px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium text-[var(--color-text)]">
|
||||
Vorschläge für <strong>{formatDayLabel(selectedDay)}</strong>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="context-detail"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
{expanded ? 'Filter ausblenden ▲' : 'Filter einblenden ▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="context-detail"
|
||||
data-testid="context-detail"
|
||||
aria-hidden={!expanded}
|
||||
{...expanded ? {} : { hidden: true }}
|
||||
>
|
||||
{#if slotsWithMeal.length > 0}
|
||||
<div class="mt-3">
|
||||
<p class="mb-1 font-[var(--font-sans)] text-[11px] uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Diese Woche bisher
|
||||
</p>
|
||||
<ul class="space-y-1">
|
||||
{#each slotsWithMeal as slot}
|
||||
<li class="flex gap-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text)]">
|
||||
<span class="text-[var(--color-text-muted)]">{formatDayLabel(slot.slotDate!).split(',')[0]}</span>
|
||||
<span>{slot.recipe?.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte diese Woche geplant
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
48
frontend/src/lib/planner/SuggestionContextBanner.test.ts
Normal file
48
frontend/src/lib/planner/SuggestionContextBanner.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import SuggestionContextBanner from './SuggestionContextBanner.svelte';
|
||||
|
||||
const weekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy' } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Hard' } }
|
||||
]
|
||||
};
|
||||
|
||||
describe('SuggestionContextBanner', () => {
|
||||
it('renders the selected day label', () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
|
||||
// Day label should be visible
|
||||
expect(screen.getByTestId('context-banner')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders meals from the current week after expanding', async () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
|
||||
// Banner starts collapsed — expand it first
|
||||
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
|
||||
await fireEvent.click(toggle);
|
||||
expect(screen.getByText(/Pasta/)).toBeTruthy();
|
||||
expect(screen.getByText(/Curry/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('starts collapsed and expands on toggle', async () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } });
|
||||
const detail = screen.getByTestId('context-detail');
|
||||
// Initially collapsed
|
||||
expect(detail.hasAttribute('hidden')).toBe(true);
|
||||
const toggle = screen.getByRole('button', { name: /Filter|einblenden/i });
|
||||
await fireEvent.click(toggle);
|
||||
// After toggle: expanded
|
||||
expect(detail.hasAttribute('hidden')).toBe(false);
|
||||
await fireEvent.click(toggle);
|
||||
// After second toggle: collapsed again
|
||||
expect(detail.hasAttribute('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders with no slots gracefully', () => {
|
||||
render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan: { ...weekPlan, slots: [] } } });
|
||||
expect(screen.getByTestId('context-banner')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
62
frontend/src/lib/planner/VarietyScoreCard.svelte
Normal file
62
frontend/src/lib/planner/VarietyScoreCard.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
interface IngredientOverlap {
|
||||
ingredientName?: string;
|
||||
days?: string[];
|
||||
}
|
||||
|
||||
let {
|
||||
score,
|
||||
ingredientOverlaps = [],
|
||||
showReviewLink = false
|
||||
}: {
|
||||
score: number;
|
||||
ingredientOverlaps?: IngredientOverlap[];
|
||||
showReviewLink?: boolean;
|
||||
} = $props();
|
||||
|
||||
let percentage = $derived(Math.round((score / 10) * 100));
|
||||
</script>
|
||||
|
||||
<div class="rounded-[var(--radius-lg)] border border-[var(--yellow-light)] bg-[var(--yellow-tint)] p-4">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="font-[var(--font-display)] text-[28px] font-[300] text-[var(--color-text)] md:text-[40px]">
|
||||
{score}
|
||||
</span>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">/10</span>
|
||||
<span class="ml-1 font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">Abwechslungs-Score</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div
|
||||
class="mt-2 h-[4px] w-full overflow-hidden rounded-full bg-[var(--yellow-light)]"
|
||||
>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={score}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={10}
|
||||
class="h-full rounded-full bg-[var(--yellow)] transition-all"
|
||||
style="width: {percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredient overlap warnings -->
|
||||
{#if ingredientOverlaps.length > 0}
|
||||
<ul class="mt-3 space-y-1">
|
||||
{#each ingredientOverlaps as overlap}
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--yellow-text)]">
|
||||
⚠ <span>{overlap.ingredientName}</span> in <span>{overlap.days?.length ?? 0} Mahlzeiten</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if showReviewLink}
|
||||
<a
|
||||
href="/planner/variety"
|
||||
class="mt-3 block font-[var(--font-sans)] text-[12px] font-medium text-[var(--yellow-text)] hover:underline"
|
||||
>
|
||||
Variety überprüfen →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
73
frontend/src/lib/planner/VarietyScoreCard.test.ts
Normal file
73
frontend/src/lib/planner/VarietyScoreCard.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import VarietyScoreCard from './VarietyScoreCard.svelte';
|
||||
|
||||
const baseProps = {
|
||||
score: 7.5,
|
||||
ingredientOverlaps: [],
|
||||
showReviewLink: false
|
||||
};
|
||||
|
||||
describe('VarietyScoreCard', () => {
|
||||
it('renders the variety score', () => {
|
||||
render(VarietyScoreCard, { props: baseProps });
|
||||
expect(screen.getByText('7.5')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders "/10" denominator', () => {
|
||||
render(VarietyScoreCard, { props: baseProps });
|
||||
expect(screen.getByText('/10')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a progress bar with correct aria attributes', () => {
|
||||
render(VarietyScoreCard, { props: baseProps });
|
||||
const bar = screen.getByRole('progressbar');
|
||||
expect(bar.getAttribute('aria-valuenow')).toBe('7.5');
|
||||
expect(bar.getAttribute('aria-valuemin')).toBe('0');
|
||||
expect(bar.getAttribute('aria-valuemax')).toBe('10');
|
||||
});
|
||||
|
||||
it('renders ingredient overlap warnings', () => {
|
||||
render(VarietyScoreCard, {
|
||||
props: {
|
||||
...baseProps,
|
||||
ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }]
|
||||
}
|
||||
});
|
||||
expect(screen.getByText(/Tomate/)).toBeTruthy();
|
||||
expect(screen.getByText(/2 Mahlzeiten/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows review link when showReviewLink is true', () => {
|
||||
render(VarietyScoreCard, { props: { ...baseProps, showReviewLink: true } });
|
||||
const link = screen.getByRole('link', { name: /Variety.*überprüfen|Review variety/i });
|
||||
expect(link).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides review link by default', () => {
|
||||
render(VarietyScoreCard, { props: baseProps });
|
||||
expect(screen.queryByRole('link', { name: /variety/i })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders with score 0', () => {
|
||||
render(VarietyScoreCard, { props: { ...baseProps, score: 0 } });
|
||||
expect(screen.getByText('0')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders multiple ingredient overlap warnings', () => {
|
||||
render(VarietyScoreCard, {
|
||||
props: {
|
||||
...baseProps,
|
||||
ingredientOverlaps: [
|
||||
{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] },
|
||||
{ ingredientName: 'Zwiebel', days: ['2026-03-30', '2026-04-01', '2026-04-02'] },
|
||||
{ ingredientName: 'Knoblauch', days: ['2026-03-31', '2026-04-01'] }
|
||||
]
|
||||
}
|
||||
});
|
||||
expect(screen.getByText(/Tomate/)).toBeTruthy();
|
||||
expect(screen.getByText(/Zwiebel/)).toBeTruthy();
|
||||
expect(screen.getByText(/Knoblauch/)).toBeTruthy();
|
||||
expect(screen.getByText(/3 Mahlzeiten/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
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>
|
||||
74
frontend/src/lib/planner/VarietyScoreHero.test.ts
Normal file
74
frontend/src/lib/planner/VarietyScoreHero.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
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)] border border-[var(--yellow-light)] 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);
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/planner/WeekStrip.svelte
Normal file
70
frontend/src/lib/planner/WeekStrip.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { weekDays, formatDayAbbr } from './week';
|
||||
|
||||
interface Slot {
|
||||
id?: string;
|
||||
slotDate?: string;
|
||||
recipe?: { id?: string; name?: string } | null;
|
||||
}
|
||||
|
||||
let {
|
||||
weekStart,
|
||||
slots = [],
|
||||
selectedDay,
|
||||
today,
|
||||
onselectDay
|
||||
}: {
|
||||
weekStart: string;
|
||||
slots?: Slot[];
|
||||
selectedDay: string;
|
||||
today: string;
|
||||
onselectDay?: (day: string) => void;
|
||||
} = $props();
|
||||
|
||||
let days = $derived(weekDays(weekStart));
|
||||
let slotMap = $derived(
|
||||
Object.fromEntries(slots.map((s) => [s.slotDate!, s]))
|
||||
);
|
||||
|
||||
function selectDay(day: string) {
|
||||
onselectDay?.(day);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-7 gap-[2px] md:gap-[6px]">
|
||||
{#each days as day}
|
||||
{@const isSelected = day === selectedDay}
|
||||
{@const isTodayDay = day === today}
|
||||
{@const hasMeal = !!slotMap[day]?.recipe}
|
||||
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
||||
{@const abbr = formatDayAbbr(day, 'narrow')}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="day-chip-{day}"
|
||||
data-selected={isSelected}
|
||||
data-today={isTodayDay}
|
||||
onclick={() => selectDay(day)}
|
||||
class="flex flex-col items-center rounded-[10px] px-1 py-2 transition-colors
|
||||
{isTodayDay ? 'border border-[var(--yellow-light)] bg-[var(--yellow-tint)]' : ''}
|
||||
{isSelected && !isTodayDay ? 'border border-[var(--green-light)] bg-[var(--green-tint)]' : ''}
|
||||
{!isTodayDay && !isSelected ? 'border border-transparent' : ''}"
|
||||
>
|
||||
<span class="font-[var(--font-sans)] text-[7px] uppercase tracking-wide text-[var(--color-text-muted)] md:text-[10px]">
|
||||
{abbr}
|
||||
</span>
|
||||
<span class="font-[var(--font-sans)] text-[11px] font-medium text-[var(--color-text)] md:text-[14px]">
|
||||
{dateNum}
|
||||
</span>
|
||||
<!-- Dot indicator -->
|
||||
<span
|
||||
data-testid="dot-{day}"
|
||||
data-has-meal={hasMeal}
|
||||
class="mt-1 h-[3px] w-[3px] rounded-full
|
||||
{hasMeal ? 'bg-[var(--green)]' : ''}
|
||||
{!hasMeal && isTodayDay ? 'bg-[var(--yellow-text)]' : ''}
|
||||
{!hasMeal && !isTodayDay ? 'bg-[var(--color-border)]' : ''}"
|
||||
></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
66
frontend/src/lib/planner/WeekStrip.test.ts
Normal file
66
frontend/src/lib/planner/WeekStrip.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import WeekStrip from './WeekStrip.svelte';
|
||||
|
||||
const weekStart = '2026-03-30'; // Monday
|
||||
|
||||
const slots = [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r1', name: 'Pasta', effort: 'Easy' } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium' } }
|
||||
];
|
||||
|
||||
describe('WeekStrip', () => {
|
||||
it('renders 7 day chips', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
const chips = screen.getAllByRole('button');
|
||||
expect(chips).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('marks today chip with data-today attribute', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
const todayChip = screen.getByTestId('day-chip-2026-04-03');
|
||||
expect(todayChip.getAttribute('data-today')).toBe('true');
|
||||
});
|
||||
|
||||
it('marks selected chip with data-selected attribute', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
const selectedChip = screen.getByTestId('day-chip-2026-03-30');
|
||||
expect(selectedChip.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows meal indicator dot for days with a meal', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
const dot = screen.getByTestId('dot-2026-03-30');
|
||||
expect(dot.getAttribute('data-has-meal')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows empty dot for days without a meal', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-03-30', today: '2026-04-03' } });
|
||||
// 2026-04-01 has no meal
|
||||
const dot = screen.getByTestId('dot-2026-04-01');
|
||||
expect(dot.getAttribute('data-has-meal')).toBe('false');
|
||||
});
|
||||
|
||||
it('when today equals selected day, both data-today and data-selected are true', () => {
|
||||
render(WeekStrip, { props: { weekStart, slots, selectedDay: '2026-04-03', today: '2026-04-03' } });
|
||||
const chip = screen.getByTestId('day-chip-2026-04-03');
|
||||
expect(chip.getAttribute('data-today')).toBe('true');
|
||||
expect(chip.getAttribute('data-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('calls onselectDay callback when chip is clicked', async () => {
|
||||
let emitted: string | null = null;
|
||||
render(WeekStrip, {
|
||||
props: {
|
||||
weekStart,
|
||||
slots,
|
||||
selectedDay: '2026-03-30',
|
||||
today: '2026-04-03',
|
||||
onselectDay: (day: string) => { emitted = day; }
|
||||
}
|
||||
});
|
||||
const chip = screen.getByTestId('day-chip-2026-03-31');
|
||||
chip.click();
|
||||
expect(emitted).toBe('2026-03-31');
|
||||
});
|
||||
});
|
||||
123
frontend/src/lib/planner/variety.test.ts
Normal file
123
frontend/src/lib/planner/variety.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
88
frontend/src/lib/planner/variety.ts
Normal file
88
frontend/src/lib/planner/variety.ts
Normal file
@@ -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;
|
||||
}
|
||||
146
frontend/src/lib/planner/week.test.ts
Normal file
146
frontend/src/lib/planner/week.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
getWeekStart,
|
||||
prevWeek,
|
||||
nextWeek,
|
||||
weekDays,
|
||||
isToday,
|
||||
formatWeekRange,
|
||||
formatDayLabel
|
||||
} from './week';
|
||||
|
||||
describe('getWeekStart', () => {
|
||||
it('returns Monday for a Monday input', () => {
|
||||
// 2026-03-30 is a Monday
|
||||
const d = new Date('2026-03-30T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Wednesday input', () => {
|
||||
// 2026-04-01 is a Wednesday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-01T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Sunday input (edge case — goes back 6 days)', () => {
|
||||
// 2026-04-05 is a Sunday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-05T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('returns Monday for a Saturday input', () => {
|
||||
// 2026-04-04 is a Saturday → week starts 2026-03-30
|
||||
const d = new Date('2026-04-04T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('handles year boundary correctly (Dec 28 2025 → week starts Dec 22 2025)', () => {
|
||||
const d = new Date('2025-12-28T12:00:00Z');
|
||||
expect(getWeekStart(d)).toBe('2025-12-22');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prevWeek', () => {
|
||||
it('returns the Monday 7 days before', () => {
|
||||
expect(prevWeek('2026-03-30')).toBe('2026-03-23');
|
||||
});
|
||||
|
||||
it('handles month boundary', () => {
|
||||
expect(prevWeek('2026-04-06')).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('handles year boundary', () => {
|
||||
expect(prevWeek('2026-01-05')).toBe('2025-12-29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextWeek', () => {
|
||||
it('returns the Monday 7 days after', () => {
|
||||
expect(nextWeek('2026-03-30')).toBe('2026-04-06');
|
||||
});
|
||||
|
||||
it('handles month boundary', () => {
|
||||
expect(nextWeek('2026-03-30')).toBe('2026-04-06');
|
||||
});
|
||||
|
||||
it('handles year boundary', () => {
|
||||
expect(nextWeek('2025-12-29')).toBe('2026-01-05');
|
||||
});
|
||||
});
|
||||
|
||||
describe('weekDays', () => {
|
||||
it('returns exactly 7 dates', () => {
|
||||
expect(weekDays('2026-03-30')).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('starts on the given weekStart', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[0]).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('ends 6 days after weekStart', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[6]).toBe('2026-04-05');
|
||||
});
|
||||
|
||||
it('has consecutive daily dates', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
for (let i = 1; i < 7; i++) {
|
||||
const prev = new Date(days[i - 1] + 'T00:00:00Z');
|
||||
const curr = new Date(days[i] + 'T00:00:00Z');
|
||||
expect(curr.getTime() - prev.getTime()).toBe(86400000);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles month boundary correctly', () => {
|
||||
const days = weekDays('2026-03-30');
|
||||
expect(days[1]).toBe('2026-03-31');
|
||||
expect(days[2]).toBe('2026-04-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToday', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns true for today (UTC)', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-03')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for yesterday', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-02')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for tomorrow', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T10:00:00Z'));
|
||||
expect(isToday('2026-04-04')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatWeekRange', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
expect(formatWeekRange('2026-03-30')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('contains both start and end month info', () => {
|
||||
const range = formatWeekRange('2026-03-30');
|
||||
// Start is March 30, end is April 5 — range should span both months
|
||||
expect(range).toContain('–');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDayLabel', () => {
|
||||
it('returns a non-empty string', () => {
|
||||
expect(formatDayLabel('2026-03-30')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('contains a comma separator', () => {
|
||||
expect(formatDayLabel('2026-03-30')).toContain(',');
|
||||
});
|
||||
});
|
||||
88
frontend/src/lib/planner/week.ts
Normal file
88
frontend/src/lib/planner/week.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Returns the ISO Monday (YYYY-MM-DD) for the week containing `date`.
|
||||
*/
|
||||
export function getWeekStart(date: Date): string {
|
||||
const d = new Date(date);
|
||||
const day = d.getUTCDay(); // 0=Sun, 1=Mon, …
|
||||
const diff = day === 0 ? -6 : 1 - day; // shift to Monday
|
||||
d.setUTCDate(d.getUTCDate() + diff);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Monday of the previous week relative to `weekStart`.
|
||||
*/
|
||||
export function prevWeek(weekStart: string): string {
|
||||
const d = new Date(weekStart + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() - 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Monday of the next week relative to `weekStart`.
|
||||
*/
|
||||
export function nextWeek(weekStart: string): string {
|
||||
const d = new Date(weekStart + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + 7);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string (YYYY-MM-DD) as a localized day abbreviation.
|
||||
*/
|
||||
export function formatDayAbbr(dateStr: string, length: 'narrow' | 'short' = 'narrow'): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
return d.toLocaleDateString('de-DE', { weekday: length, timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of 7 date strings for the week starting on `weekStart`.
|
||||
*/
|
||||
export function weekDays(weekStart: string): string[] {
|
||||
const days: string[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(weekStart + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
days.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string as "Mo, 30.03." style label.
|
||||
*/
|
||||
export function formatDayLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
const day = d.toLocaleDateString('de-DE', { weekday: 'short', timeZone: 'UTC' });
|
||||
const date = d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', timeZone: 'UTC' });
|
||||
return `${day}, ${date}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date string as "30. März" style label.
|
||||
*/
|
||||
export function formatDayFull(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'long', timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if dateStr is today (UTC date).
|
||||
* Uses UTC consistently with all other date functions in this module.
|
||||
*/
|
||||
export function isToday(dateStr: string): boolean {
|
||||
const todayStr = new Date().toISOString().slice(0, 10);
|
||||
return dateStr === todayStr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a week range: "30. Mär – 5. Apr 2026".
|
||||
*/
|
||||
export function formatWeekRange(weekStart: string): string {
|
||||
const start = new Date(weekStart + 'T00:00:00Z');
|
||||
const end = new Date(weekStart + 'T00:00:00Z');
|
||||
end.setUTCDate(end.getUTCDate() + 6);
|
||||
const startStr = start.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', timeZone: 'UTC' });
|
||||
const endStr = end.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
20
frontend/src/lib/recipes/FilterChipRow.svelte
Normal file
20
frontend/src/lib/recipes/FilterChipRow.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
let { activeFilter, onFilter }: { activeFilter: string; onFilter: (filter: string) => void } = $props();
|
||||
|
||||
const chips = ['Alle', 'Leicht', 'Mittel', 'Schwer'];
|
||||
</script>
|
||||
|
||||
<div class="flex gap-[8px] overflow-x-auto px-[16px] py-[12px] scrollbar-none">
|
||||
{#each chips as label (label)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activeFilter === label}
|
||||
onclick={() => onFilter(label)}
|
||||
class="font-sans text-[13px] font-medium tracking-[0.04em] px-[14px] py-[5px] rounded-[12px] border cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)] {activeFilter === label
|
||||
? 'bg-[var(--green-tint)] text-[var(--green-dark)] border-[var(--green-light)]'
|
||||
: 'bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--color-border)]'}"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
51
frontend/src/lib/recipes/FilterChipRow.test.ts
Normal file
51
frontend/src/lib/recipes/FilterChipRow.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import FilterChipRow from './FilterChipRow.svelte';
|
||||
|
||||
describe('FilterChipRow', () => {
|
||||
it('renders all effort filter chips', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Mittel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Schwer' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks active chip with aria-pressed=true', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'Alle' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('marks inactive chips with aria-pressed=false', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } });
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Mittel' })).toHaveAttribute('aria-pressed', 'false');
|
||||
expect(screen.getByRole('button', { name: 'Schwer' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
it('calls onFilter with the chip label when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilter = vi.fn();
|
||||
render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leicht' }));
|
||||
expect(onFilter).toHaveBeenCalledWith('Leicht');
|
||||
});
|
||||
|
||||
it('calls onFilter with Alle when reset chip clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFilter = vi.fn();
|
||||
render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Alle' }));
|
||||
expect(onFilter).toHaveBeenCalledWith('Alle');
|
||||
});
|
||||
|
||||
it('active chip has green-tint styling', () => {
|
||||
render(FilterChipRow, { props: { activeFilter: 'Mittel', onFilter: vi.fn() } });
|
||||
const btn = screen.getByRole('button', { name: 'Mittel' });
|
||||
expect(btn.className).toContain('bg-[var(--green-tint)]');
|
||||
});
|
||||
});
|
||||
30
frontend/src/lib/recipes/IngredientList.svelte
Normal file
30
frontend/src/lib/recipes/IngredientList.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Ingredient } from './types';
|
||||
|
||||
let { ingredients }: { ingredients: Ingredient[] } = $props();
|
||||
|
||||
const sortedIngredients = $derived(
|
||||
[...ingredients].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
|
||||
>
|
||||
Zutaten
|
||||
</h2>
|
||||
|
||||
<ul>
|
||||
{#each sortedIngredients as ingredient (ingredient.ingredientId ?? ingredient.name)}
|
||||
<li class="flex items-baseline gap-[12px] py-[8px] border-b border-[var(--color-border)] last:border-b-0">
|
||||
{#if ingredient.quantity != null}
|
||||
<span class="text-[13px] font-medium text-[var(--color-text)] w-[80px] shrink-0">
|
||||
{ingredient.quantity}{ingredient.unit != null ? ` ${ingredient.unit}` : ''}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-[14px] text-[var(--color-text)]">{ingredient.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
57
frontend/src/lib/recipes/IngredientList.test.ts
Normal file
57
frontend/src/lib/recipes/IngredientList.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import IngredientList from './IngredientList.svelte';
|
||||
|
||||
const mockIngredients = [
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
|
||||
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' },
|
||||
{ ingredientId: 'i3', name: 'Salz', quantity: undefined, unit: undefined }
|
||||
];
|
||||
|
||||
describe('IngredientList', () => {
|
||||
it('renders the section heading', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a row for each ingredient', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
|
||||
expect(screen.getByText('Salz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quantity and unit when present', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.getByText('200 g')).toBeInTheDocument();
|
||||
expect(screen.getByText('400 g')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no quantity when not present', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.queryByText('undefined')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has no remove buttons (read-only)', () => {
|
||||
render(IngredientList, { props: { ingredients: mockIngredients } });
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when ingredients array is empty', () => {
|
||||
render(IngredientList, { props: { ingredients: [] } });
|
||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ingredients sorted by sortOrder', () => {
|
||||
const unsorted = [
|
||||
{ ingredientId: 'i3', name: 'Oregano', quantity: 1, unit: 'TL', sortOrder: 3 },
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g', sortOrder: 1 },
|
||||
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g', sortOrder: 2 }
|
||||
];
|
||||
render(IngredientList, { props: { ingredients: unsorted } });
|
||||
const spans = document.querySelectorAll('li span:last-child');
|
||||
expect(spans[0].textContent).toBe('Spaghetti');
|
||||
expect(spans[1].textContent).toBe('Hackfleisch');
|
||||
expect(spans[2].textContent).toBe('Oregano');
|
||||
});
|
||||
});
|
||||
60
frontend/src/lib/recipes/RecipeCard.svelte
Normal file
60
frontend/src/lib/recipes/RecipeCard.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { RecipeSummary } from './types';
|
||||
|
||||
let { recipe, compact = false }: { recipe: RecipeSummary; compact?: boolean } = $props();
|
||||
|
||||
let metadata = $derived(
|
||||
[
|
||||
recipe.cookTimeMin != null ? `${recipe.cookTimeMin} Min` : null,
|
||||
recipe.effort ?? null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/recipes/{recipe.id}"
|
||||
class="block rounded-[var(--radius-md)] overflow-hidden border border-[var(--color-border)] bg-[var(--color-surface)] hover:shadow-[var(--shadow-card)]"
|
||||
>
|
||||
<div
|
||||
data-testid="image-area"
|
||||
class="w-full overflow-hidden {compact ? 'h-[64px]' : 'h-[100px]'}"
|
||||
>
|
||||
{#if recipe.heroImageUrl}
|
||||
<img src={recipe.heroImageUrl} alt={recipe.name} class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div
|
||||
data-testid="image-placeholder"
|
||||
class="w-full h-full bg-[var(--color-border)] flex items-center justify-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
class="text-[var(--color-text-muted)] opacity-50"
|
||||
>
|
||||
<!-- plate -->
|
||||
<circle cx="12" cy="13" r="6" />
|
||||
<path d="M12 7V5" />
|
||||
<!-- fork tines -->
|
||||
<path d="M8 3v3c0 1.1.9 2 2 2h4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="px-2 py-1.5">
|
||||
<p class="font-medium text-[13px] text-[var(--color-text)] truncate">{recipe.name}</p>
|
||||
{#if metadata}
|
||||
<p class="text-[12px] text-[var(--color-text-muted)]">{metadata}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
62
frontend/src/lib/recipes/RecipeCard.test.ts
Normal file
62
frontend/src/lib/recipes/RecipeCard.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import RecipeCard from './RecipeCard.svelte';
|
||||
|
||||
const mockRecipe = {
|
||||
id: 'recipe-1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined
|
||||
};
|
||||
|
||||
describe('RecipeCard', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cook time when present', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText(/30/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders effort when present', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
expect(screen.getByText(/easy/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows placeholder when no heroImageUrl', () => {
|
||||
render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows image when heroImageUrl is provided', () => {
|
||||
render(RecipeCard, {
|
||||
props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } }
|
||||
});
|
||||
const img = screen.getByRole('img');
|
||||
expect(img).toHaveAttribute('src', '/uploads/test.jpg');
|
||||
expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese');
|
||||
});
|
||||
|
||||
it('wraps in a link to the recipe detail page', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe } });
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toHaveAttribute('href', '/recipes/recipe-1');
|
||||
});
|
||||
|
||||
it('applies compact image height when compact prop is true', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, compact: true } });
|
||||
const imageArea = document.querySelector('[data-testid="image-area"]');
|
||||
expect(imageArea?.className).toContain('h-[64px]');
|
||||
});
|
||||
|
||||
it('applies full image height when compact prop is false', () => {
|
||||
render(RecipeCard, { props: { recipe: mockRecipe, compact: false } });
|
||||
const imageArea = document.querySelector('[data-testid="image-area"]');
|
||||
expect(imageArea?.className).toContain('h-[100px]');
|
||||
});
|
||||
});
|
||||
282
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
282
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
@@ -0,0 +1,282 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
type Category = { id: string; name: string; tagType?: string };
|
||||
|
||||
type EditRecipe = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
ingredients: { name: string; quantity: number; unit: string }[];
|
||||
steps: { instruction: string }[];
|
||||
tagIds: string[];
|
||||
} | null;
|
||||
|
||||
const { recipe, categories, action }: {
|
||||
recipe: EditRecipe;
|
||||
categories: Category[];
|
||||
action: string;
|
||||
} = $props();
|
||||
|
||||
const effortOptions = [
|
||||
{ label: 'Leicht', value: 'Easy' },
|
||||
{ label: 'Mittel', value: 'Medium' },
|
||||
{ label: 'Schwer', value: 'Hard' }
|
||||
];
|
||||
|
||||
const initial = (() => $state.snapshot(recipe))();
|
||||
|
||||
let name = $state(initial?.name ?? '');
|
||||
let serves = $state<number | ''>(initial?.serves ?? '');
|
||||
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
|
||||
let effort = $state(initial?.effort ?? '');
|
||||
let selectedTagIds = $state<string[]>(initial?.tagIds ? [...initial.tagIds] : []);
|
||||
let ingredients = $state(
|
||||
initial?.ingredients.map((ing) => ({
|
||||
name: ing.name,
|
||||
quantity: ing.quantity as number | '',
|
||||
unit: ing.unit
|
||||
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
|
||||
);
|
||||
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
||||
</script>
|
||||
|
||||
<form method="POST" {action} use:enhance>
|
||||
<!-- Error banner -->
|
||||
{#if $page.form?.error}
|
||||
<div
|
||||
role="alert"
|
||||
class="mb-[20px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]"
|
||||
>
|
||||
{$page.form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div class="md:flex md:gap-[32px]">
|
||||
<!-- Left column: main form fields -->
|
||||
<div class="md:flex-1">
|
||||
<!-- Basic info -->
|
||||
<div class="mb-[24px]">
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="name"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="serves"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Portionen
|
||||
</label>
|
||||
<input
|
||||
id="serves"
|
||||
name="serves"
|
||||
type="number"
|
||||
bind:value={serves}
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-[16px]">
|
||||
<label
|
||||
for="cookTimeMin"
|
||||
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
|
||||
>
|
||||
Kochzeit
|
||||
</label>
|
||||
<input
|
||||
id="cookTimeMin"
|
||||
name="cookTimeMin"
|
||||
type="number"
|
||||
bind:value={cookTimeMin}
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Effort chips -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">
|
||||
Schwierigkeitsgrad
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each effortOptions as opt (opt.value)}
|
||||
<label
|
||||
class={[
|
||||
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
|
||||
effort === opt.value
|
||||
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
|
||||
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
|
||||
].join(' ')}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="effort"
|
||||
value={opt.value}
|
||||
bind:group={effort}
|
||||
class="sr-only"
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Zutaten</p>
|
||||
<div class="flex flex-col gap-[8px]">
|
||||
{#each ingredients as ing, i (i)}
|
||||
<div class="flex items-center gap-[8px]">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={ing.quantity}
|
||||
placeholder="Menge"
|
||||
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={ing.unit}
|
||||
placeholder="Einheit"
|
||||
class="w-[80px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={ing.name}
|
||||
placeholder="Zutat"
|
||||
class="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}
|
||||
class="shrink-0 text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '' as number | '', unit: '' }])}
|
||||
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
|
||||
>
|
||||
Zutat hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mb-[24px]">
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Schritte</p>
|
||||
<div class="flex flex-col gap-[12px]">
|
||||
{#each steps as _, i (i)}
|
||||
<div class="flex items-start gap-[12px]">
|
||||
<span
|
||||
class="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-full bg-[var(--green-tint)] text-[12px] font-medium text-[var(--green-dark)]"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div class="flex flex-1 flex-col gap-[6px]">
|
||||
<textarea
|
||||
bind:value={steps[i]}
|
||||
placeholder="Schritt beschreiben…"
|
||||
rows="3"
|
||||
class="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none resize-none"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (steps = steps.filter((_, j) => j !== i))}
|
||||
class="self-start text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-error)] cursor-pointer"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (steps = [...steps, ''])}
|
||||
class="mt-[12px] text-[13px] font-medium text-[var(--green-dark)] cursor-pointer"
|
||||
>
|
||||
Schritt hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: categories -->
|
||||
<div class="md:w-[280px] md:flex-shrink-0 mt-[24px] md:mt-0">
|
||||
<div
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-[20px]"
|
||||
>
|
||||
<p class="mb-[12px] text-[13px] font-medium text-[var(--color-text)]">Kategorien</p>
|
||||
<div class="flex flex-wrap gap-[8px]">
|
||||
{#each categories as cat (cat.id)}
|
||||
<label
|
||||
class={[
|
||||
'cursor-pointer rounded-[var(--radius-full)] border px-[12px] py-[6px] text-[13px] font-medium',
|
||||
selectedTagIds.includes(cat.id)
|
||||
? 'bg-[var(--green-tint)] border-[var(--green-dark)] text-[var(--green-dark)]'
|
||||
: 'bg-white border-[var(--color-border)] text-[var(--color-text-muted)]'
|
||||
].join(' ')}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="tagIds"
|
||||
value={cat.id}
|
||||
checked={selectedTagIds.includes(cat.id)}
|
||||
onchange={(e) => {
|
||||
if (e.currentTarget.checked) {
|
||||
selectedTagIds = [...selectedTagIds, cat.id];
|
||||
} else {
|
||||
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
|
||||
}
|
||||
}}
|
||||
class="sr-only"
|
||||
/>
|
||||
{cat.name}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden inputs for form submission -->
|
||||
<input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} />
|
||||
<input type="hidden" name="stepsJson" value={JSON.stringify(steps)} />
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-[32px] flex items-center justify-between">
|
||||
<a
|
||||
href="/recipes"
|
||||
class="text-[13px] font-medium text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
165
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
165
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { writable } from 'svelte/store';
|
||||
import RecipeForm from './RecipeForm.svelte';
|
||||
|
||||
vi.mock('$app/stores', () => ({
|
||||
page: writable({ form: null, url: new URL('http://localhost/recipes/new') })
|
||||
}));
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: () => ({ destroy: () => {} })
|
||||
}));
|
||||
|
||||
const mockCategories = [
|
||||
{ id: 'c1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 'c2', name: 'Fleisch', tagType: 'category' }
|
||||
];
|
||||
|
||||
const emptyProps = {
|
||||
recipe: null,
|
||||
categories: mockCategories,
|
||||
action: '?/create'
|
||||
};
|
||||
|
||||
const editProps = {
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Medium',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
ingredients: [
|
||||
{ name: 'Spaghetti', quantity: 200, unit: 'g' }
|
||||
],
|
||||
steps: [
|
||||
{ instruction: 'Wasser aufsetzen' }
|
||||
],
|
||||
tagIds: ['c1']
|
||||
},
|
||||
categories: mockCategories,
|
||||
action: '?/update'
|
||||
};
|
||||
|
||||
describe('RecipeForm', () => {
|
||||
it('renders recipe name input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders serves input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/portionen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cook time input', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByLabelText(/kochzeit/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills name when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByLabelText(/name/i)).toHaveValue('Spaghetti Bolognese');
|
||||
});
|
||||
|
||||
it('prefills serves when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByLabelText(/portionen/i)).toHaveValue(4);
|
||||
});
|
||||
|
||||
it('renders effort chips', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('radio', { name: /leicht/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /mittel/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /schwer/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills effort when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByRole('radio', { name: /mittel/i })).toBeChecked();
|
||||
});
|
||||
|
||||
it('renders category chips', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: 'Fleisch' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills selected categories when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeChecked();
|
||||
expect(screen.getByRole('checkbox', { name: 'Fleisch' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('renders at least one ingredient row initially for empty form', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByPlaceholderText(/zutat/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills ingredient rows when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByDisplayValue('Spaghetti')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds ingredient row when "Zutat hinzufügen" is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const before = screen.getAllByPlaceholderText(/zutat/i).length;
|
||||
await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i }));
|
||||
expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('removes ingredient row when remove button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: editProps });
|
||||
await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i }));
|
||||
const before = screen.getAllByPlaceholderText(/zutat/i).length;
|
||||
const removeButtons = screen.getAllByRole('button', { name: /entfernen/i });
|
||||
await user.click(removeButtons[0]);
|
||||
expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before - 1);
|
||||
});
|
||||
|
||||
it('renders at least one step row initially for empty form', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByPlaceholderText(/schritt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefills step rows when editing', () => {
|
||||
render(RecipeForm, { props: editProps });
|
||||
expect(screen.getByDisplayValue('Wasser aufsetzen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds step row when "Schritt hinzufügen" is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const before = screen.getAllByPlaceholderText(/schritt/i).length;
|
||||
await user.click(screen.getByRole('button', { name: /schritt hinzufügen/i }));
|
||||
expect(screen.getAllByPlaceholderText(/schritt/i)).toHaveLength(before + 1);
|
||||
});
|
||||
|
||||
it('renders save button', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('button', { name: /speichern/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cancel link back to /recipes', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
const cancelLink = screen.getByRole('link', { name: /abbrechen/i });
|
||||
expect(cancelLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('displays form error message when $page.form.error is set', async () => {
|
||||
const { page } = await import('$app/stores');
|
||||
(page as ReturnType<typeof writable>).set({ form: { error: 'Name ist erforderlich' }, url: new URL('http://localhost/recipes/new') });
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Name ist erforderlich');
|
||||
(page as ReturnType<typeof writable>).set({ form: null, url: new URL('http://localhost/recipes/new') });
|
||||
});
|
||||
|
||||
it('does not display error banner when form has no error', () => {
|
||||
render(RecipeForm, { props: emptyProps });
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
21
frontend/src/lib/recipes/RecipeGrid.svelte
Normal file
21
frontend/src/lib/recipes/RecipeGrid.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import RecipeCard from './RecipeCard.svelte';
|
||||
import type { RecipeSummary } from './types';
|
||||
|
||||
let { recipes }: { recipes: RecipeSummary[] } = $props();
|
||||
</script>
|
||||
|
||||
{#if recipes.length > 0}
|
||||
<div data-testid="recipe-grid" class="grid grid-cols-2 lg:grid-cols-4 gap-[8px] lg:gap-[12px] p-[16px]">
|
||||
{#each recipes as recipe (recipe.id)}
|
||||
<RecipeCard {recipe} compact={true} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-[48px] px-[24px] text-center">
|
||||
<p class="text-[var(--color-text-muted)] text-[14px] mb-[16px]">Noch keine Rezepte vorhanden.</p>
|
||||
<a href="/recipes/new" class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white">
|
||||
Rezept hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
41
frontend/src/lib/recipes/RecipeGrid.test.ts
Normal file
41
frontend/src/lib/recipes/RecipeGrid.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import RecipeGrid from './RecipeGrid.svelte';
|
||||
|
||||
const mockRecipes = [
|
||||
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
|
||||
{ id: 'r3', name: 'Caesar Salad', cookTimeMin: 15, effort: 'Easy' }
|
||||
];
|
||||
|
||||
describe('RecipeGrid', () => {
|
||||
it('renders a card for each recipe', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Caesar Salad')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders 3 links for 3 recipes', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('shows empty state when recipes array is empty', () => {
|
||||
render(RecipeGrid, { props: { recipes: [] } });
|
||||
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('empty state links to recipe creation', () => {
|
||||
render(RecipeGrid, { props: { recipes: [] } });
|
||||
const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i });
|
||||
expect(addLink).toHaveAttribute('href', '/recipes/new');
|
||||
});
|
||||
|
||||
it('grid has 2-col mobile and 4-col desktop classes', () => {
|
||||
render(RecipeGrid, { props: { recipes: mockRecipes } });
|
||||
const grid = document.querySelector('[data-testid="recipe-grid"]');
|
||||
expect(grid?.className).toContain('grid-cols-2');
|
||||
expect(grid?.className).toContain('lg:grid-cols-4');
|
||||
});
|
||||
});
|
||||
70
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
70
frontend/src/lib/recipes/RecipeHero.svelte
Normal file
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import type { Tag } from './types';
|
||||
|
||||
type RecipeHeroData = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
tags: Tag[];
|
||||
};
|
||||
|
||||
let { recipe }: { recipe: RecipeHeroData } = $props();
|
||||
|
||||
let hasImage = $derived(!!recipe.heroImageUrl);
|
||||
|
||||
let pillBase = $derived(
|
||||
hasImage
|
||||
? 'bg-white/20 text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||
: 'bg-[var(--color-border)] text-[var(--color-text-muted)] text-[13px] font-medium font-sans px-[10px] py-[4px] rounded-full'
|
||||
);
|
||||
|
||||
let cookBtnClass = $derived(
|
||||
hasImage
|
||||
? 'font-sans text-[13px] font-medium tracking-[0.04em] bg-white text-[var(--green-dark)] rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||
: 'font-sans text-[13px] font-medium tracking-[0.04em] bg-[var(--green-dark)] text-white rounded-[var(--radius-md)] px-[24px] py-[12px] mt-[16px] inline-block'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="recipe-hero"
|
||||
class="min-h-[200px] md:min-h-[240px] {hasImage
|
||||
? 'relative text-white'
|
||||
: 'bg-[var(--green-tint)] text-[var(--color-text)]'} p-[24px] md:p-[32px]"
|
||||
>
|
||||
{#if hasImage}
|
||||
<img
|
||||
src={recipe.heroImageUrl}
|
||||
alt={recipe.name}
|
||||
class="object-cover w-full h-full absolute inset-0"
|
||||
/>
|
||||
<div class="absolute inset-0" style="background: rgba(0,0,0,0.5);"></div>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<a href="/recipes" class="text-[13px] font-sans font-medium text-[var(--color-text-muted)]">← Zurück</a>
|
||||
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] mt-[8px]">
|
||||
{recipe.name}
|
||||
</h1>
|
||||
|
||||
<div class="flex gap-[8px] flex-wrap mt-[12px]">
|
||||
{#if recipe.cookTimeMin != null}
|
||||
<span class={pillBase}>{recipe.cookTimeMin} Min</span>
|
||||
{/if}
|
||||
{#if recipe.effort}
|
||||
<span class={pillBase}>{recipe.effort}</span>
|
||||
{/if}
|
||||
{#if recipe.serves != null}
|
||||
<span class={pillBase}>{recipe.serves} Port.</span>
|
||||
{/if}
|
||||
{#each recipe.tags as tag (tag.id)}
|
||||
<span class={pillBase}>{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<a href="/cook/{recipe.id}" class={cookBtnClass}>Jetzt kochen</a>
|
||||
</div>
|
||||
</div>
|
||||
87
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
87
frontend/src/lib/recipes/RecipeHero.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import RecipeHero from './RecipeHero.svelte';
|
||||
|
||||
const baseRecipe = {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
tags: [] as { id: string; name: string; tagType?: string }[]
|
||||
};
|
||||
|
||||
describe('RecipeHero', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders green-tint hero when no image', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const hero = document.querySelector('[data-testid="recipe-hero"]');
|
||||
expect(hero?.className).toContain('bg-[var(--green-tint)]');
|
||||
});
|
||||
|
||||
it('renders image when heroImageUrl is provided', () => {
|
||||
render(RecipeHero, {
|
||||
props: { recipe: { ...baseRecipe, heroImageUrl: '/uploads/pasta.jpg' } }
|
||||
});
|
||||
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||
});
|
||||
|
||||
it('renders cook time pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/30 Min/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders effort pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/Easy/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders serves pill', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.getByText(/4/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders back link to /recipes', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const backLink = screen.getByRole('link', { name: /zurück/i });
|
||||
expect(backLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('renders cook now link to /cook/[id]', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
|
||||
expect(cookLink).toHaveAttribute('href', '/cook/r1');
|
||||
});
|
||||
|
||||
it('does not render img when no heroImageUrl', () => {
|
||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tag pills', () => {
|
||||
render(RecipeHero, {
|
||||
props: {
|
||||
recipe: {
|
||||
...baseRecipe,
|
||||
tags: [
|
||||
{ id: 't1', name: 'Pasta' },
|
||||
{ id: 't2', name: 'Italienisch' }
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||
expect(screen.getByText('Italienisch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no tag pills when tags array is empty', () => {
|
||||
render(RecipeHero, { props: { recipe: { ...baseRecipe, tags: [] } } });
|
||||
expect(screen.queryByText('Pasta')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
33
frontend/src/lib/recipes/StepList.svelte
Normal file
33
frontend/src/lib/recipes/StepList.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { Step } from './types';
|
||||
|
||||
let { steps }: { steps: Step[] } = $props();
|
||||
|
||||
const sortedSteps = $derived(
|
||||
[...steps].sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
|
||||
);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<h2
|
||||
class="text-[12px] font-medium font-sans tracking-[0.08em] uppercase text-[var(--color-text-muted)] mb-[16px]"
|
||||
>
|
||||
Zubereitung
|
||||
</h2>
|
||||
<ol>
|
||||
{#each sortedSteps as step (step.stepNumber)}
|
||||
<li class="flex gap-[16px] items-start mb-[20px]">
|
||||
<div
|
||||
data-testid="step-circle"
|
||||
aria-hidden="true"
|
||||
class="w-[28px] h-[28px] rounded-full bg-[var(--green-dark)] text-white flex items-center justify-center shrink-0 font-sans text-[13px] font-medium"
|
||||
>
|
||||
{step.stepNumber}
|
||||
</div>
|
||||
<p class="text-[14px] text-[var(--color-text)] leading-[1.6] pt-[4px]">
|
||||
{step.instruction}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
50
frontend/src/lib/recipes/StepList.test.ts
Normal file
50
frontend/src/lib/recipes/StepList.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import StepList from './StepList.svelte';
|
||||
|
||||
const mockSteps = [
|
||||
{ stepNumber: 1, instruction: 'Wasser zum Kochen bringen' },
|
||||
{ stepNumber: 2, instruction: 'Spaghetti al dente kochen' },
|
||||
{ stepNumber: 3, instruction: 'Sauce bereiten' }
|
||||
];
|
||||
|
||||
describe('StepList', () => {
|
||||
it('renders the section heading', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders each step instruction', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText('Wasser zum Kochen bringen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Spaghetti al dente kochen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sauce bereiten')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step numbers', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders numbered circles with step numbers', () => {
|
||||
render(StepList, { props: { steps: mockSteps } });
|
||||
const circles = document.querySelectorAll('[data-testid="step-circle"]');
|
||||
expect(circles).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders steps in stepNumber order', () => {
|
||||
const shuffled = [mockSteps[2], mockSteps[0], mockSteps[1]];
|
||||
render(StepList, { props: { steps: shuffled } });
|
||||
const circles = document.querySelectorAll('[data-testid="step-circle"]');
|
||||
expect(circles[0].textContent).toBe('1');
|
||||
expect(circles[1].textContent).toBe('2');
|
||||
expect(circles[2].textContent).toBe('3');
|
||||
});
|
||||
|
||||
it('renders empty state when no steps', () => {
|
||||
render(StepList, { props: { steps: [] } });
|
||||
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
38
frontend/src/lib/recipes/types.ts
Normal file
38
frontend/src/lib/recipes/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type RecipeSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
id: string;
|
||||
name: string;
|
||||
tagType?: string;
|
||||
};
|
||||
|
||||
export type Ingredient = {
|
||||
ingredientId?: string;
|
||||
name?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
export type Step = {
|
||||
stepNumber?: number;
|
||||
instruction?: string;
|
||||
};
|
||||
|
||||
export type RecipeDetail = {
|
||||
id: string;
|
||||
name: string;
|
||||
serves?: number;
|
||||
cookTimeMin?: number;
|
||||
effort?: string;
|
||||
heroImageUrl?: string;
|
||||
ingredients: Ingredient[];
|
||||
steps: Step[];
|
||||
tags: Tag[];
|
||||
};
|
||||
55
frontend/src/routes/(app)/planner/+page.server.ts
Normal file
55
frontend/src/routes/(app)/planner/+page.server.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { PageServerLoad, Actions } 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 } = await api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
});
|
||||
|
||||
if (error || !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
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
createPlan: async ({ fetch, request, locals }) => {
|
||||
// Role guard: only planners may create week plans
|
||||
if (locals.benutzer?.rolle !== 'planer') {
|
||||
return { success: false, error: 'Keine Berechtigung.' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const weekStart = formData.get('weekStart') as string;
|
||||
|
||||
// Validate weekStart format: must be YYYY-MM-DD
|
||||
if (!weekStart || !/^\d{4}-\d{2}-\d{2}$/.test(weekStart)) {
|
||||
return { success: false, error: 'Ungültiges Datum.' };
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans', {
|
||||
body: { weekStart }
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: 'Plan konnte nicht erstellt werden.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
@@ -1 +1,349 @@
|
||||
<h1 class="text-2xl font-medium p-6">Planer</h1>
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import VarietyScoreCard from '$lib/planner/VarietyScoreCard.svelte';
|
||||
import WeekStrip from '$lib/planner/WeekStrip.svelte';
|
||||
import DayMealCard from '$lib/planner/DayMealCard.svelte';
|
||||
import { prevWeek, nextWeek, getWeekStart, weekDays, formatDayLabel, formatDayAbbr, formatWeekRange } from '$lib/planner/week';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// Capture initial weekStart before reactivity for $state initialization
|
||||
const initialWeekStart: string = data.weekStart;
|
||||
// Use UTC date string (YYYY-MM-DD) consistently
|
||||
const today: string = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let weekStart = $derived(data.weekStart);
|
||||
let weekPlan = $derived(data.weekPlan);
|
||||
let varietyScore = $derived(data.varietyScore);
|
||||
|
||||
let days = $derived(weekDays(weekStart));
|
||||
let slots = $derived(weekPlan?.slots ?? []);
|
||||
let slotMap = $derived(Object.fromEntries(slots.map((s: any) => [s.slotDate, s])));
|
||||
|
||||
// Default selected day: today if in this week, else first day
|
||||
let selectedDay = $state(weekDays(initialWeekStart).includes(today) ? today : weekDays(initialWeekStart)[0]);
|
||||
|
||||
// When week changes via navigation, reset selected day
|
||||
$effect(() => {
|
||||
const newDays = weekDays(weekStart);
|
||||
if (!newDays.includes(selectedDay)) {
|
||||
selectedDay = newDays.includes(today) ? today : newDays[0];
|
||||
}
|
||||
});
|
||||
|
||||
let selectedSlot = $derived(slotMap[selectedDay] ?? { id: null, slotDate: selectedDay, recipe: null });
|
||||
let remainingSlots = $derived(days.filter((d: string) => d > selectedDay).map((d: string) => slotMap[d] ?? { id: null, slotDate: d, recipe: null }));
|
||||
let remainingSlotsWithMeal = $derived(remainingSlots.filter((s: any) => s.recipe));
|
||||
|
||||
let isPlanner = $derived((data as any).benutzer?.rolle === 'planer');
|
||||
|
||||
let weekRange = $derived(formatWeekRange(weekStart));
|
||||
|
||||
function handleSelectDay(day: string) {
|
||||
selectedDay = day;
|
||||
}
|
||||
|
||||
async function navigateWeek(direction: 'prev' | 'next' | 'today') {
|
||||
let newWeekStart: string;
|
||||
if (direction === 'prev') newWeekStart = prevWeek(weekStart);
|
||||
else if (direction === 'next') newWeekStart = nextWeek(weekStart);
|
||||
else newWeekStart = getWeekStart(new Date());
|
||||
|
||||
await goto(`/planner?week=${newWeekStart}`, { invalidateAll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile & Tablet: vertical stack -->
|
||||
<div class="flex h-full flex-col lg:hidden">
|
||||
<!-- Top nav (sticky) -->
|
||||
<header class="sticky top-0 z-10 flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-page)] px-4 py-3">
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Diese Woche</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('prev')}
|
||||
aria-label="Vorherige Woche"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] p-1.5 text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('next')}
|
||||
aria-label="Nächste Woche"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] p-1.5 text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
+ Gericht
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Variety banner: sticky below the top nav so it's always visible (spec requirement) -->
|
||||
{#if varietyScore}
|
||||
<div class="sticky z-10 px-4 pt-3" style="top: 56px;">
|
||||
<VarietyScoreCard
|
||||
score={varietyScore.score ?? 0}
|
||||
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
|
||||
showReviewLink={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Day strip -->
|
||||
<div class="px-4 pt-3">
|
||||
<WeekStrip
|
||||
{weekStart}
|
||||
{slots}
|
||||
{selectedDay}
|
||||
{today}
|
||||
onselectDay={handleSelectDay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selected day card -->
|
||||
<div class="px-4 pt-4">
|
||||
<p class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(selectedDay)}
|
||||
</p>
|
||||
<DayMealCard
|
||||
slot={selectedSlot}
|
||||
isToday={selectedDay === today}
|
||||
isSelected={true}
|
||||
readonly={!isPlanner}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Remaining days list -->
|
||||
{#if remainingSlotsWithMeal.length > 0}
|
||||
<div class="px-4 pt-6 pb-4">
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Restliche Woche
|
||||
</h2>
|
||||
<div class="space-y-2 md:grid md:grid-cols-2 md:gap-2 md:space-y-0">
|
||||
{#each remainingSlotsWithMeal as slot}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectDay(slot.slotDate)}
|
||||
class="flex w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left hover:border-[var(--green-light)]"
|
||||
>
|
||||
<span class="min-w-[36px] font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(slot.slotDate).split(',')[0]}
|
||||
</span>
|
||||
<span class="flex-1 truncate font-[var(--font-sans)] text-[14px] font-medium text-[var(--color-text)]">
|
||||
{slot.recipe?.name}
|
||||
</span>
|
||||
{#if isPlanner}
|
||||
<span class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">→</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Empty week state -->
|
||||
{#if !weekPlan}
|
||||
<div class="flex flex-1 flex-col items-center justify-center px-4 py-8 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Noch kein Wochenplan für diese Woche.</p>
|
||||
{#if isPlanner}
|
||||
<form method="POST" action="?/createPlan" class="mt-4">
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Wochenplan erstellen
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 3-panel layout -->
|
||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||
<!-- Topbar -->
|
||||
<header class="flex items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-page)] px-6 py-4">
|
||||
<h1 class="font-[var(--font-display)] text-[20px] font-[300] text-[var(--color-text)]">Wochenplaner</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('prev')}
|
||||
aria-label="Vorherige Woche"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">{weekRange}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('next')}
|
||||
aria-label="Nächste Woche"
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => navigateWeek('today')}
|
||||
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
</div>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
+ Gericht hinzufügen
|
||||
</a>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left sidebar -->
|
||||
<aside class="flex w-[224px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
||||
<!-- Variety widget at bottom -->
|
||||
{#if varietyScore}
|
||||
<div class="mt-auto">
|
||||
<VarietyScoreCard
|
||||
score={varietyScore.score ?? 0}
|
||||
ingredientOverlaps={varietyScore.ingredientOverlaps ?? []}
|
||||
showReviewLink={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- Main calendar (only scrollable panel) -->
|
||||
<main class="flex-1 overflow-y-auto p-5">
|
||||
{#if !weekPlan}
|
||||
<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 kein Wochenplan für diese Woche.</p>
|
||||
{#if isPlanner}
|
||||
<form method="POST" action="?/createPlan" class="mt-4">
|
||||
<input type="hidden" name="weekStart" value={weekStart} />
|
||||
<button type="submit" class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white">
|
||||
Wochenplan erstellen
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-7 gap-[8px]">
|
||||
{#each days as day}
|
||||
{@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }}
|
||||
{@const isTodayDay = day === today}
|
||||
{@const isSelectedDay = day === selectedDay}
|
||||
{@const dateNum = day.slice(-2).replace(/^0/, '')}
|
||||
{@const dayAbbr = formatDayAbbr(day, 'narrow')}
|
||||
|
||||
<div class="flex flex-col">
|
||||
<!-- Column header: day name + date badge -->
|
||||
<div class="mb-2 flex flex-col items-center gap-1">
|
||||
<p class="font-[var(--font-sans)] text-[9px] uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{dayAbbr}
|
||||
</p>
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-medium
|
||||
{isTodayDay ? 'bg-[var(--yellow)] text-white' : ''}
|
||||
{isSelectedDay && !isTodayDay ? 'bg-[var(--green-tint)] text-[var(--green-dark)]' : ''}
|
||||
{!isTodayDay && !isSelectedDay ? 'bg-transparent text-[var(--color-text)]' : ''}"
|
||||
>
|
||||
{dateNum}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meal tile -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectDay(day)}
|
||||
aria-label={slot.recipe ? slot.recipe.name : `Gericht wählen für ${formatDayLabel(day)}`}
|
||||
class="flex flex-1 flex-col rounded-[var(--radius-lg)] border p-2 text-left shadow-[var(--shadow-card)] transition-all hover:border-[var(--green-light)] hover:shadow-[var(--shadow-raised)]
|
||||
{slot.recipe && !isTodayDay && !isSelectedDay ? 'border-[var(--color-border)] bg-[var(--color-surface)]' : ''}
|
||||
{isTodayDay && slot.recipe ? 'border-2 border-[var(--yellow)] bg-[var(--yellow-tint)]' : ''}
|
||||
{isSelectedDay && !isTodayDay && slot.recipe ? 'border-2 border-[var(--green)] bg-[var(--green-tint)]' : ''}
|
||||
{!slot.recipe ? 'border-dashed border-[var(--color-border)] bg-transparent' : ''}"
|
||||
>
|
||||
{#if slot.recipe}
|
||||
<p class="font-[var(--font-display)] text-[13px] font-[300] leading-tight text-[var(--color-text)]">
|
||||
{slot.recipe.name}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-center justify-center py-4 text-[var(--color-text-muted)]">
|
||||
<span class="text-[18px]" aria-hidden="true">+</span>
|
||||
<span class="font-[var(--font-sans)] text-[11px]">Gericht wählen</span>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<!-- Right detail panel -->
|
||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-page)] p-4">
|
||||
<div class="mb-3">
|
||||
<p class="font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(selectedDay)} · Abendessen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if selectedSlot?.recipe}
|
||||
<h2 class="font-[var(--font-display)] text-[17px] font-[300] text-[var(--color-text)]">
|
||||
{selectedSlot.recipe.name}
|
||||
</h2>
|
||||
{#if selectedSlot.recipe.effort || selectedSlot.recipe.cookTimeMin}
|
||||
<p class="mt-1 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
{[selectedSlot.recipe.cookTimeMin ? `${selectedSlot.recipe.cookTimeMin} Min` : null, selectedSlot.recipe.effort].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- View and cook actions shown to all roles -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<a
|
||||
href="/recipes/{selectedSlot.recipe.id}"
|
||||
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Rezept ansehen
|
||||
</a>
|
||||
<a
|
||||
href="/recipes/{selectedSlot.recipe.id}/cook"
|
||||
class="block rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white"
|
||||
>
|
||||
Koch-Modus
|
||||
</a>
|
||||
<!-- Swap action: planner only -->
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="block rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
||||
>
|
||||
Gericht tauschen
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">Kein Gericht geplant</p>
|
||||
{#if isPlanner}
|
||||
<a
|
||||
href="/planner/suggestions?day={selectedDay}"
|
||||
class="mt-3 block rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-3 py-2 text-center text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-[var(--color-text-muted)]"
|
||||
>
|
||||
+ Gericht wählen
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
195
frontend/src/routes/(app)/planner/page.server.test.ts
Normal file
195
frontend/src/routes/(app)/planner/page.server.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
||||
}));
|
||||
|
||||
describe('planner page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
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: 30 } },
|
||||
{ id: 's2', slotDate: '2026-03-31', recipe: { id: 'r2', name: 'Curry', effort: 'Medium', cookTimeMin: 45 } }
|
||||
]
|
||||
};
|
||||
|
||||
it('fetches week plan for the current week by default', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({
|
||||
data: { score: 7.5, ingredientOverlaps: [], tagRepeats: [], recentRepeats: [], duplicatesInPlan: [] },
|
||||
error: undefined
|
||||
});
|
||||
const url = new URL('http://localhost/planner');
|
||||
await load({ fetch: vi.fn(), url });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: expect.objectContaining({ weekStart: expect.any(String) }) }) }));
|
||||
});
|
||||
|
||||
it('uses weekStart from URL search params if provided', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 8 }, error: undefined });
|
||||
const url = new URL('http://localhost/planner?week=2026-03-30');
|
||||
await load({ fetch: vi.fn(), url });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ params: expect.objectContaining({ query: { weekStart: '2026-03-30' } }) }));
|
||||
});
|
||||
|
||||
it('returns weekPlan with slots in page data', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 7.5 }, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.id).toBe('plan-1');
|
||||
expect(result.weekPlan.slots).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('returns variety score in page data', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 7.5, ingredientOverlaps: [{ ingredientName: 'Tomate', days: ['2026-03-30', '2026-03-31'] }] }, error: undefined });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.varietyScore.score).toBe(7.5);
|
||||
expect(result.varietyScore.ingredientOverlaps).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null weekPlan when API returns 404', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the weekStart used for the query', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: { score: 6 }, error: undefined });
|
||||
const url = new URL('http://localhost/planner?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekStart).toBe('2026-03-30');
|
||||
});
|
||||
|
||||
it('creates week plan if not found and fetches variety score after creation', async () => {
|
||||
// When no plan exists (404), load should return null weekPlan — creation is done via a POST action, not in load
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner page — actions', () => {
|
||||
let actions: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('createPlan action calls POST /v1/week-plans', async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: 'plan-new', weekStart: '2026-03-30', slots: [] }, error: undefined });
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans', expect.objectContaining({ body: { weekStart: '2026-03-30' } }));
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('createPlan action returns error when API fails', async () => {
|
||||
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
||||
});
|
||||
|
||||
it('createPlan action returns error for invalid weekStart format', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', 'not-a-date');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Ungültiges Datum.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('createPlan action returns error when weekStart is missing', async () => {
|
||||
const formData = new FormData();
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Ungültiges Datum.' });
|
||||
});
|
||||
|
||||
it('createPlan action returns permission error for member role', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.createPlan({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' }, haushalt: { id: 'h1', name: 'Test' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner page — variety score partial failure', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: []
|
||||
};
|
||||
|
||||
it('returns weekPlan even when variety score API fails', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||
const url = new URL('http://localhost/planner');
|
||||
const result = await load({ fetch: vi.fn(), url });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.id).toBe('plan-1');
|
||||
expect(result.varietyScore).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
import { getWeekStart } from '$lib/planner/week';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, url, locals: _locals }) => {
|
||||
const weekParam = url.searchParams.get('week');
|
||||
const weekStart = weekParam ?? getWeekStart(new Date());
|
||||
const selectedDay = url.searchParams.get('day') ?? weekStart;
|
||||
|
||||
const api = apiClient(fetch);
|
||||
|
||||
// Load the week plan for context (week-so-far display)
|
||||
const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', {
|
||||
params: { query: { weekStart } }
|
||||
});
|
||||
|
||||
if (weekPlanError || !weekPlan?.id) {
|
||||
return { weekPlan: null, suggestions: [], selectedDay, weekStart };
|
||||
}
|
||||
|
||||
// Load variety-aware suggestions for the selected day
|
||||
const { data: suggestionsData } = await api.GET('/v1/week-plans/{id}/suggestions', {
|
||||
params: { path: { id: weekPlan.id }, query: { slotDate: selectedDay } }
|
||||
});
|
||||
|
||||
// Sort by simulatedScore descending (highest = best variety fit)
|
||||
const suggestions = (suggestionsData?.suggestions ?? []).sort(
|
||||
(a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0)
|
||||
);
|
||||
|
||||
return { weekPlan, suggestions, selectedDay, weekStart };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
pickSuggestion: async ({ fetch, request, locals }) => {
|
||||
// Role guard: only planners may assign meals
|
||||
if (locals.benutzer?.rolle !== 'planer') {
|
||||
return { success: false, error: 'Keine Berechtigung.' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const planId = formData.get('planId') as string;
|
||||
const recipeId = formData.get('recipeId') as string;
|
||||
const slotDate = formData.get('slotDate') as string;
|
||||
const weekStart = formData.get('weekStart') as string;
|
||||
|
||||
// Validate slotDate format
|
||||
if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) {
|
||||
return { success: false, error: 'Ungültiges Datum.' };
|
||||
}
|
||||
|
||||
// Validate planId is non-empty
|
||||
if (!planId) {
|
||||
return { success: false, error: 'Fehlende Plan-ID.' };
|
||||
}
|
||||
|
||||
// Validate recipeId is UUID-like format
|
||||
if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) {
|
||||
return { success: false, error: 'Ungültige Rezept-ID.' };
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
||||
params: { path: { id: planId } },
|
||||
body: { slotDate, recipeId }
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' };
|
||||
}
|
||||
|
||||
// Redirect back to the planner after successful pick (spec: "returns to C1")
|
||||
redirect(303, `/planner?week=${weekStart || slotDate.slice(0, 7) + '-01'}`);
|
||||
}
|
||||
};
|
||||
199
frontend/src/routes/(app)/planner/suggestions/+page.svelte
Normal file
199
frontend/src/routes/(app)/planner/suggestions/+page.svelte
Normal file
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import SuggestionCard from '$lib/planner/SuggestionCard.svelte';
|
||||
import SuggestionContextBanner from '$lib/planner/SuggestionContextBanner.svelte';
|
||||
import { formatDayLabel } from '$lib/planner/week';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let weekPlan = $derived(data.weekPlan);
|
||||
let suggestions = $derived(data.suggestions ?? []);
|
||||
let selectedDay = $derived(data.selectedDay);
|
||||
let weekStart = $derived(data.weekStart);
|
||||
|
||||
// Add rank and derive reasoning from simulatedScore for display.
|
||||
// TODO: replace hardcoded threshold (7.5) with API-provided reasoning once backend supports it.
|
||||
let rankedSuggestions = $derived(
|
||||
suggestions.map((s: any, i: number) => ({
|
||||
...s,
|
||||
reasoningType: (s.simulatedScore ?? 0) >= 7.5 ? 'good' : 'warning',
|
||||
reasoningLabel:
|
||||
(s.simulatedScore ?? 0) >= 7.5
|
||||
? 'Passt gut zur Woche'
|
||||
: 'Wiederholung möglich'
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Gerichtsvorschläge — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Mobile layout: full-width list with context banner -->
|
||||
<div class="flex h-full flex-col lg:hidden">
|
||||
<!-- Mobile 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)]">
|
||||
Vorschläge für {formatDayLabel(selectedDay)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<!-- Context banner -->
|
||||
<div class="px-4 pt-3">
|
||||
<SuggestionContextBanner {selectedDay} {weekPlan} />
|
||||
</div>
|
||||
|
||||
<!-- Suggestion list -->
|
||||
<div class="flex-1 overflow-y-auto px-4 pt-4 pb-6">
|
||||
{#if rankedSuggestions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Keine Vorschläge verfügbar.
|
||||
</p>
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="mt-3 font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each rankedSuggestions as suggestion, i}
|
||||
<SuggestionCard
|
||||
{suggestion}
|
||||
rank={i + 1}
|
||||
planId={weekPlan?.id ?? ''}
|
||||
slotDate={selectedDay}
|
||||
{weekStart}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Browse full library fallback -->
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: 2-panel layout -->
|
||||
<div class="hidden h-screen lg:flex lg:flex-col">
|
||||
<!-- Topbar -->
|
||||
<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}"
|
||||
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-[20px] font-[300] text-[var(--color-text)]">
|
||||
Vorschläge für {formatDayLabel(selectedDay)}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Left context panel (280px) -->
|
||||
<aside class="flex w-[280px] flex-shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)] p-5 overflow-y-auto">
|
||||
<h2 class="mb-3 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Diese Woche bisher
|
||||
</h2>
|
||||
|
||||
{#if weekPlan?.slots?.length}
|
||||
<ul class="space-y-2">
|
||||
{#each (weekPlan.slots ?? []).filter((s: any) => s.slotDate !== selectedDay) as slot}
|
||||
<li class="flex items-baseline gap-2">
|
||||
<span class="min-w-[28px] font-[var(--font-sans)] text-[11px] text-[var(--color-text-muted)]">
|
||||
{formatDayLabel(slot.slotDate ?? '').split(',')[0]}
|
||||
</span>
|
||||
{#if slot.recipe}
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text)] truncate">
|
||||
{slot.recipe.name}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">— Nicht geplant</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)]">
|
||||
Noch keine Gerichte diese Woche geplant.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Filter reasons -->
|
||||
<div class="mt-6">
|
||||
<h3 class="mb-2 font-[var(--font-sans)] text-[12px] font-medium uppercase tracking-wide text-[var(--color-text-muted)]">
|
||||
Filterkriterien
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Keine Zutatenwiederholungen (3 Tage)
|
||||
</li>
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Protein-Abwechslung beachten
|
||||
</li>
|
||||
<li class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)]">
|
||||
· Aufwandsbalance
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Browse library link in desktop panel footer -->
|
||||
<div class="mt-auto pt-6">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Bibliothek →
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right suggestions panel -->
|
||||
<main class="flex-1 overflow-y-auto bg-[var(--color-page)] px-6 py-5">
|
||||
{#if rankedSuggestions.length === 0}
|
||||
<div class="flex h-full flex-col items-center justify-center">
|
||||
<p class="font-[var(--font-sans)] text-[14px] text-[var(--color-text-muted)]">
|
||||
Keine Vorschläge verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each rankedSuggestions as suggestion, i}
|
||||
<SuggestionCard
|
||||
{suggestion}
|
||||
rank={i + 1}
|
||||
planId={weekPlan?.id ?? ''}
|
||||
slotDate={selectedDay}
|
||||
{weekStart}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<a
|
||||
href="/recipes?selectFor={selectedDay}&week={weekStart}"
|
||||
class="font-[var(--font-sans)] text-[13px] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:underline"
|
||||
>
|
||||
Gesamte Rezeptbibliothek durchsuchen →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
||||
}));
|
||||
|
||||
describe('suggestions page — load', () => {
|
||||
let load: any;
|
||||
|
||||
const mockSuggestions = {
|
||||
suggestions: [
|
||||
{
|
||||
recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 },
|
||||
simulatedScore: 9.2
|
||||
},
|
||||
{
|
||||
recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 },
|
||||
simulatedScore: 6.1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const mockWeekPlan = {
|
||||
id: 'plan-1',
|
||||
weekStart: '2026-03-30',
|
||||
status: 'draft',
|
||||
slots: [
|
||||
{ id: 's1', slotDate: '2026-03-30', recipe: { id: 'r3', name: 'Pasta', effort: 'Easy' } }
|
||||
]
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
it('fetches suggestions for the given plan and day', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({
|
||||
params: expect.objectContaining({ path: { id: 'plan-1' } })
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns suggestions list sorted by simulatedScore descending', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.suggestions[0].recipe.name).toBe('Pasta al Limone');
|
||||
expect(result.suggestions[1].recipe.name).toBe('Hühnchen Curry');
|
||||
});
|
||||
|
||||
it('returns the selectedDay from URL params', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.selectedDay).toBe('2026-04-01');
|
||||
});
|
||||
|
||||
it('returns empty suggestions when API fails', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns week plan slots for context display', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.weekPlan).toBeDefined();
|
||||
expect(result.weekPlan.slots).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns null weekPlan and empty suggestions when week plan not found', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.weekPlan).toBeNull();
|
||||
expect(result.suggestions).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults day to weekStart when no day param provided', async () => {
|
||||
mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||
mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined });
|
||||
const url = new URL('http://localhost/planner/suggestions?week=2026-03-30');
|
||||
const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } });
|
||||
expect(result.selectedDay).toBe('2026-03-30');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestions page — pickSuggestion action', () => {
|
||||
let actions: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('adds a slot to the week plan via POST and redirects to planner', async () => {
|
||||
mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined });
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
try {
|
||||
await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect.unreachable();
|
||||
} catch (e: any) {
|
||||
expect(e.status).toBe(303);
|
||||
expect(e.location).toBe('/planner?week=2026-03-30');
|
||||
}
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({
|
||||
params: { path: { id: 'plan-1' } },
|
||||
body: { slotDate: '2026-04-01', recipeId: '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f' }
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns error when planId is missing', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', '');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error for invalid recipeId format', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', 'not-a-uuid');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Ungültige Rezept-ID.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when API fails', async () => {
|
||||
mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } });
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
||||
});
|
||||
|
||||
it('returns permission error for member role', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', '2026-04-01');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error for invalid slotDate format', async () => {
|
||||
const formData = new FormData();
|
||||
formData.set('planId', 'plan-1');
|
||||
formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f');
|
||||
formData.set('slotDate', 'not-a-date');
|
||||
formData.set('weekStart', '2026-03-30');
|
||||
const result = await actions.pickSuggestion({
|
||||
fetch: vi.fn(),
|
||||
request: { formData: async () => formData },
|
||||
locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } }
|
||||
});
|
||||
expect(result).toEqual({ success: false, error: expect.any(String) });
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
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
|
||||
};
|
||||
};
|
||||
233
frontend/src/routes/(app)/planner/variety/+page.svelte
Normal file
233
frontend/src/routes/(app)/planner/variety/+page.svelte
Normal file
@@ -0,0 +1,233 @@
|
||||
<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 { computeSubScores, computeWarnings } from '$lib/planner/variety';
|
||||
|
||||
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.by(() => {
|
||||
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.by(() => computeSubScores({
|
||||
tagRepeats: varietyScore?.tagRepeats ?? [],
|
||||
ingredientOverlaps: varietyScore?.ingredientOverlaps ?? [],
|
||||
...effortCounts
|
||||
}));
|
||||
|
||||
// Build warning list from API data
|
||||
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.by(() => {
|
||||
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} />
|
||||
</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} />
|
||||
</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 aria-hidden="true" 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} />
|
||||
</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 (weekDayKeys[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.split(' ')[0].slice(0, 3).toUpperCase() : '—'}
|
||||
</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} />
|
||||
</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}$/);
|
||||
});
|
||||
});
|
||||
21
frontend/src/routes/(app)/recipes/+page.server.ts
Normal file
21
frontend/src/routes/(app)/recipes/+page.server.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.GET('/v1/recipes', {});
|
||||
|
||||
if (error || !data?.data) {
|
||||
return { recipes: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
recipes: data.data.map((r) => ({
|
||||
id: r.id!,
|
||||
name: r.name!,
|
||||
cookTimeMin: r.cookTimeMin,
|
||||
effort: r.effort,
|
||||
heroImageUrl: r.heroImageUrl
|
||||
}))
|
||||
};
|
||||
};
|
||||
@@ -1 +1,47 @@
|
||||
<h1 class="text-2xl font-medium p-6">Rezepte</h1>
|
||||
<script lang="ts">
|
||||
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
|
||||
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
|
||||
import type { RecipeSummary } from '$lib/recipes/types';
|
||||
|
||||
let { data }: { data: { recipes: RecipeSummary[] } } = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let activeFilter = $state('Alle');
|
||||
|
||||
const effortMap: Record<string, string> = {
|
||||
Leicht: 'Easy',
|
||||
Mittel: 'Medium',
|
||||
Schwer: 'Hard'
|
||||
};
|
||||
|
||||
let filteredRecipes = $derived(
|
||||
data.recipes
|
||||
.filter((r) => {
|
||||
if (activeFilter === 'Alle') return true;
|
||||
return r.effort === effortMap[activeFilter];
|
||||
})
|
||||
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rezepte — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">Rezepte</h1>
|
||||
<a href="/recipes/new" class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white">Rezept hinzufügen</a>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Suchen…"
|
||||
class="input"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
|
||||
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
|
||||
|
||||
<RecipeGrid recipes={filteredRecipes} />
|
||||
</div>
|
||||
|
||||
41
frontend/src/routes/(app)/recipes/[id]/+page.server.ts
Normal file
41
frontend/src/routes/(app)/recipes/[id]/+page.server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||
const api = apiClient(fetch);
|
||||
const { data, error: apiError } = await api.GET('/v1/recipes/{id}', {
|
||||
params: { path: { id: params.id } }
|
||||
});
|
||||
|
||||
if (apiError || !data) {
|
||||
error(404, 'Recipe not found');
|
||||
}
|
||||
|
||||
return {
|
||||
recipe: {
|
||||
id: data.id!,
|
||||
name: data.name!,
|
||||
serves: data.serves,
|
||||
cookTimeMin: data.cookTimeMin,
|
||||
effort: data.effort,
|
||||
heroImageUrl: data.heroImageUrl,
|
||||
ingredients: (data.ingredients ?? []).map((ing) => ({
|
||||
ingredientId: ing.ingredientId,
|
||||
name: ing.name,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
sortOrder: ing.sortOrder
|
||||
})),
|
||||
steps: (data.steps ?? []).map((s) => ({
|
||||
stepNumber: s.stepNumber,
|
||||
instruction: s.instruction
|
||||
})),
|
||||
tags: (data.tags ?? []).map((t) => ({
|
||||
id: t.id!,
|
||||
name: t.name!,
|
||||
tagType: t.tagType
|
||||
}))
|
||||
}
|
||||
};
|
||||
};
|
||||
34
frontend/src/routes/(app)/recipes/[id]/+page.svelte
Normal file
34
frontend/src/routes/(app)/recipes/[id]/+page.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import RecipeHero from '$lib/recipes/RecipeHero.svelte';
|
||||
import IngredientList from '$lib/recipes/IngredientList.svelte';
|
||||
import StepList from '$lib/recipes/StepList.svelte';
|
||||
import type { RecipeDetail } from '$lib/recipes/types';
|
||||
|
||||
let { data }: { data: { recipe: RecipeDetail } } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.recipe.name} — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div>
|
||||
<div class="hidden md:flex items-center justify-end px-[24px] py-[12px] border-b border-[var(--color-border)]">
|
||||
<a
|
||||
href="/recipes/{data.recipe.id}/edit"
|
||||
class="border border-[var(--color-border)] text-[var(--color-text)] text-[13px] font-medium font-sans tracking-[0.04em] rounded-[var(--radius-md)] px-[16px] py-[8px]"
|
||||
>
|
||||
Bearbeiten
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<RecipeHero recipe={data.recipe} />
|
||||
|
||||
<div class="md:flex">
|
||||
<div class="md:flex-1 md:border-r md:border-[var(--color-border)] p-[24px]">
|
||||
<IngredientList ingredients={data.recipe.ingredients} />
|
||||
</div>
|
||||
<div class="md:flex-1 p-[24px]">
|
||||
<StepList steps={data.recipe.steps} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
98
frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts
Normal file
98
frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { error, redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||
const api = apiClient(fetch);
|
||||
const [recipeResult, tagsResult] = await Promise.all([
|
||||
api.GET('/v1/recipes/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/v1/tags', {})
|
||||
]);
|
||||
|
||||
if (recipeResult.error || !recipeResult.data) {
|
||||
error(404, 'Recipe not found');
|
||||
}
|
||||
|
||||
const recipe = recipeResult.data;
|
||||
const allTags = tagsResult.data ?? [];
|
||||
const categories = allTags
|
||||
.filter((t) => t.tagType === 'category')
|
||||
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
|
||||
|
||||
return {
|
||||
recipe: {
|
||||
id: recipe.id!,
|
||||
name: recipe.name!,
|
||||
serves: recipe.serves,
|
||||
cookTimeMin: recipe.cookTimeMin,
|
||||
effort: recipe.effort,
|
||||
heroImageUrl: recipe.heroImageUrl,
|
||||
ingredients: (recipe.ingredients ?? []).map((ing) => ({
|
||||
name: ing.name ?? '',
|
||||
quantity: ing.quantity ?? 0,
|
||||
unit: ing.unit ?? ''
|
||||
})),
|
||||
steps: (recipe.steps ?? [])
|
||||
.sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
|
||||
.map((s) => ({ instruction: s.instruction ?? '' })),
|
||||
tagIds: (recipe.tags ?? []).map((t) => t.id!)
|
||||
},
|
||||
categories
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ request, fetch, params }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const serves = formData.get('serves');
|
||||
const cookTimeMin = formData.get('cookTimeMin');
|
||||
const effort = formData.get('effort') as string;
|
||||
const ingredientsJson = formData.get('ingredientsJson') as string;
|
||||
const stepsJson = formData.get('stepsJson') as string;
|
||||
const tagIds = formData.getAll('tagIds') as string[];
|
||||
|
||||
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
||||
if (!effort || !VALID_EFFORTS.includes(effort))
|
||||
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
|
||||
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
||||
|
||||
let parsedIngredients: unknown[];
|
||||
let parsedSteps: unknown[];
|
||||
try {
|
||||
parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
||||
parsedSteps = JSON.parse(stepsJson || '[]');
|
||||
} catch {
|
||||
return fail(400, { error: 'Ungültige Formulardaten' });
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error: apiError } = await api.PUT('/v1/recipes/{id}', {
|
||||
params: { path: { id: params.id } },
|
||||
body: {
|
||||
name: name.trim(),
|
||||
serves: serves ? Number(serves) || undefined : undefined,
|
||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
|
||||
effort,
|
||||
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
|
||||
.filter((ing) => ing.name?.trim())
|
||||
.map((ing, i) => ({
|
||||
newIngredientName: ing.name.trim(),
|
||||
quantity: Number(ing.quantity) || 0,
|
||||
unit: ing.unit || '',
|
||||
sortOrder: i
|
||||
})),
|
||||
steps: (parsedSteps as string[])
|
||||
.filter((s) => s?.trim())
|
||||
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
||||
tagIds
|
||||
}
|
||||
});
|
||||
|
||||
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
|
||||
|
||||
redirect(303, '/recipes');
|
||||
}
|
||||
};
|
||||
17
frontend/src/routes/(app)/recipes/[id]/edit/+page.svelte
Normal file
17
frontend/src/routes/(app)/recipes/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.recipe?.name ?? 'Rezept bearbeiten'} — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-[24px]">
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
|
||||
Rezept bearbeiten
|
||||
</h1>
|
||||
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/update" />
|
||||
</div>
|
||||
190
frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts
Normal file
190
frontend/src/routes/(app)/recipes/[id]/edit/page.server.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPut = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, PUT: mockPut })
|
||||
}));
|
||||
|
||||
describe('edit recipe page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPut.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockRecipe = {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
ingredients: [{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }],
|
||||
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
|
||||
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
|
||||
};
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 't2', name: 'Fleisch', tagType: 'category' }
|
||||
];
|
||||
|
||||
it('fetches recipe and tags in parallel', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||
});
|
||||
await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns recipe data mapped for form', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||
});
|
||||
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(result.recipe.name).toBe('Spaghetti Bolognese');
|
||||
expect(result.recipe.effort).toBe('Easy');
|
||||
});
|
||||
|
||||
it('returns categories from tags', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||
});
|
||||
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(result.categories).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('throws 404 when recipe not found', async () => {
|
||||
mockGet.mockImplementation((url: string) => {
|
||||
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: undefined, error: { status: 404 } });
|
||||
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||
});
|
||||
await expect(
|
||||
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit recipe page — update action', () => {
|
||||
let actions: any;
|
||||
|
||||
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
|
||||
const base: Record<string, string | string[]> = {
|
||||
name: 'Test Rezept',
|
||||
effort: 'Easy',
|
||||
tagIds: ['t1'],
|
||||
ingredientsJson: '[]',
|
||||
stepsJson: '[]',
|
||||
...overrides
|
||||
};
|
||||
const fd = new FormData();
|
||||
for (const [key, val] of Object.entries(base)) {
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) fd.append(key, v);
|
||||
} else {
|
||||
fd.append(key, val);
|
||||
}
|
||||
}
|
||||
return fd;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPut.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('returns fail(422) when name is missing', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ name: '' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when effort is missing', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ effort: '' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when effort is not a valid value', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ effort: 'VeryHard' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when no tagIds', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ tagIds: [] }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(400) when ingredientsJson is invalid JSON', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns fail(400) when stepsJson is invalid JSON', async () => {
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData({ stepsJson: '{broken' }) },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('calls PUT /v1/recipes/{id} with correct body on success', async () => {
|
||||
mockPut.mockResolvedValue({ error: undefined });
|
||||
const fd = makeFormData({
|
||||
ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
|
||||
stepsJson: JSON.stringify(['Kochen'])
|
||||
});
|
||||
await actions.update({
|
||||
request: { formData: async () => fd },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any).catch(() => {});
|
||||
expect(mockPut).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||
params: { path: { id: 'r1' } },
|
||||
body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' })
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns fail(500) when API returns error', async () => {
|
||||
mockPut.mockResolvedValue({ error: { status: 500 } });
|
||||
const result = await actions.update({
|
||||
request: { formData: async () => makeFormData() },
|
||||
fetch: vi.fn(),
|
||||
params: { id: 'r1' }
|
||||
} as any);
|
||||
expect(result.status).toBe(500);
|
||||
});
|
||||
});
|
||||
66
frontend/src/routes/(app)/recipes/[id]/page.server.test.ts
Normal file
66
frontend/src/routes/(app)/recipes/[id]/page.server.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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 })
|
||||
}));
|
||||
|
||||
describe('recipe detail page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockRecipe = {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined,
|
||||
ingredients: [
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }
|
||||
],
|
||||
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
|
||||
tags: []
|
||||
};
|
||||
|
||||
it('fetches recipe from GET /v1/recipes/{id}', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
|
||||
await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
|
||||
params: { path: { id: 'r1' } }
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns recipe data on success', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
|
||||
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||
expect(result.recipe.name).toBe('Spaghetti Bolognese');
|
||||
expect(result.recipe.serves).toBe(4);
|
||||
});
|
||||
|
||||
it('throws 404 error when API returns error', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } });
|
||||
await expect(
|
||||
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('throws 404 error when API returns 403 (different household — intentional)', async () => {
|
||||
// Security design: we return 404 for both "not found" and "forbidden"
|
||||
// to avoid revealing resource existence to unauthorized users
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 403 } });
|
||||
await expect(
|
||||
load({ fetch: vi.fn(), params: { id: 'r-other-household' } } as any)
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
});
|
||||
92
frontend/src/routes/(app)/recipes/[id]/page.test.ts
Normal file
92
frontend/src/routes/(app)/recipes/[id]/page.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
const mockData = {
|
||||
recipe: {
|
||||
id: 'r1',
|
||||
name: 'Spaghetti Bolognese',
|
||||
serves: 4,
|
||||
cookTimeMin: 30,
|
||||
effort: 'Easy',
|
||||
heroImageUrl: undefined as string | undefined,
|
||||
ingredients: [
|
||||
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
|
||||
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' }
|
||||
],
|
||||
steps: [
|
||||
{ stepNumber: 1, instruction: 'Wasser aufsetzen' },
|
||||
{ stepNumber: 2, instruction: 'Sauce zubereiten' }
|
||||
],
|
||||
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
|
||||
}
|
||||
};
|
||||
|
||||
describe('recipe detail page', () => {
|
||||
it('renders the recipe name', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders page title', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(document.title).toBe('Spaghetti Bolognese — Mealplan');
|
||||
});
|
||||
|
||||
it('renders back link to /recipes', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
const backLink = screen.getByRole('link', { name: /zurück/i });
|
||||
expect(backLink).toHaveAttribute('href', '/recipes');
|
||||
});
|
||||
|
||||
it('renders cook now link to /cook/[id]', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
|
||||
expect(cookLink).toHaveAttribute('href', '/cook/r1');
|
||||
});
|
||||
|
||||
it('renders ingredients section heading', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders steps section heading', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders ingredient names', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Spaghetti')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step instructions', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Wasser aufsetzen')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sauce zubereiten')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders edit link to /recipes/[id]/edit', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
const editLink = screen.getByRole('link', { name: /bearbeiten/i });
|
||||
expect(editLink).toHaveAttribute('href', '/recipes/r1/edit');
|
||||
});
|
||||
|
||||
it('renders tag pills in hero', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders hero image when heroImageUrl is provided', () => {
|
||||
render(Page, {
|
||||
props: {
|
||||
data: {
|
||||
recipe: { ...mockData.recipe, heroImageUrl: '/uploads/pasta.jpg' }
|
||||
}
|
||||
}
|
||||
});
|
||||
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||
});
|
||||
});
|
||||
70
frontend/src/routes/(app)/recipes/new/+page.server.ts
Normal file
70
frontend/src/routes/(app)/recipes/new/+page.server.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
const VALID_EFFORTS = ['Easy', 'Medium', 'Hard'];
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.GET('/v1/tags', {});
|
||||
|
||||
const allTags = error || !data ? [] : data;
|
||||
const categories = allTags
|
||||
.filter((t) => t.tagType === 'category')
|
||||
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
|
||||
|
||||
return { recipe: null, categories };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const serves = formData.get('serves');
|
||||
const cookTimeMin = formData.get('cookTimeMin');
|
||||
const effort = formData.get('effort') as string;
|
||||
const ingredientsJson = formData.get('ingredientsJson') as string;
|
||||
const stepsJson = formData.get('stepsJson') as string;
|
||||
const tagIds = formData.getAll('tagIds') as string[];
|
||||
|
||||
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
||||
if (!effort || !VALID_EFFORTS.includes(effort))
|
||||
return fail(422, { error: 'Ungültiger Schwierigkeitsgrad' });
|
||||
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
||||
|
||||
let parsedIngredients: unknown[];
|
||||
let parsedSteps: unknown[];
|
||||
try {
|
||||
parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
||||
parsedSteps = JSON.parse(stepsJson || '[]');
|
||||
} catch {
|
||||
return fail(400, { error: 'Ungültige Formulardaten' });
|
||||
}
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error: apiError } = await api.POST('/v1/recipes', {
|
||||
body: {
|
||||
name: name.trim(),
|
||||
serves: serves ? Number(serves) || undefined : undefined,
|
||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) || undefined : undefined,
|
||||
effort,
|
||||
ingredients: (parsedIngredients as { name: string; quantity: string; unit: string }[])
|
||||
.filter((ing) => ing.name?.trim())
|
||||
.map((ing, i) => ({
|
||||
newIngredientName: ing.name.trim(),
|
||||
quantity: Number(ing.quantity) || 0,
|
||||
unit: ing.unit || '',
|
||||
sortOrder: i
|
||||
})),
|
||||
steps: (parsedSteps as string[])
|
||||
.filter((s) => s?.trim())
|
||||
.map((s, i) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
||||
tagIds
|
||||
}
|
||||
});
|
||||
|
||||
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
|
||||
|
||||
redirect(303, '/recipes');
|
||||
}
|
||||
};
|
||||
17
frontend/src/routes/(app)/recipes/new/+page.svelte
Normal file
17
frontend/src/routes/(app)/recipes/new/+page.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neues Rezept — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-[24px]">
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
|
||||
Neues Rezept
|
||||
</h1>
|
||||
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/create" />
|
||||
</div>
|
||||
154
frontend/src/routes/(app)/recipes/new/page.server.test.ts
Normal file
154
frontend/src/routes/(app)/recipes/new/page.server.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
||||
}));
|
||||
|
||||
describe('new recipe page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 't2', name: 'Fleisch', tagType: 'category' }
|
||||
];
|
||||
|
||||
it('fetches tags from GET /v1/tags', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
await load({ fetch: vi.fn() } as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/tags', expect.anything());
|
||||
});
|
||||
|
||||
it('returns categories filtered from tags', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.categories).toHaveLength(2);
|
||||
expect(result.categories[0].name).toBe('Pasta');
|
||||
});
|
||||
|
||||
it('returns empty categories when API fails', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.categories).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null recipe for new form', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.recipe).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('new recipe page — create action', () => {
|
||||
let actions: any;
|
||||
|
||||
const makeFormData = (overrides: Record<string, string | string[]> = {}) => {
|
||||
const base: Record<string, string | string[]> = {
|
||||
name: 'Test Rezept',
|
||||
effort: 'Easy',
|
||||
tagIds: ['t1'],
|
||||
ingredientsJson: '[]',
|
||||
stepsJson: '[]',
|
||||
...overrides
|
||||
};
|
||||
const fd = new FormData();
|
||||
for (const [key, val] of Object.entries(base)) {
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) fd.append(key, v);
|
||||
} else {
|
||||
fd.append(key, val);
|
||||
}
|
||||
}
|
||||
return fd;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
actions = mod.actions;
|
||||
});
|
||||
|
||||
it('returns fail(422) when name is missing', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ name: '' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when effort is missing', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ effort: '' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when effort is not a valid value', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ effort: 'InvalidEffort' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(422) when no tagIds', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ tagIds: [] }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(422);
|
||||
});
|
||||
|
||||
it('returns fail(400) when ingredientsJson is invalid JSON', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ ingredientsJson: 'not-json' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns fail(400) when stepsJson is invalid JSON', async () => {
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData({ stepsJson: '{broken' }) },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(400);
|
||||
});
|
||||
|
||||
it('calls POST /v1/recipes with correct body on success', async () => {
|
||||
mockPost.mockResolvedValue({ error: undefined });
|
||||
const fd = makeFormData({
|
||||
ingredientsJson: JSON.stringify([{ name: 'Spaghetti', quantity: 200, unit: 'g' }]),
|
||||
stepsJson: JSON.stringify(['Kochen'])
|
||||
});
|
||||
await actions.create({ request: { formData: async () => fd }, fetch: vi.fn() } as any).catch(
|
||||
() => {}
|
||||
);
|
||||
expect(mockPost).toHaveBeenCalledWith('/v1/recipes', expect.objectContaining({ body: expect.objectContaining({ name: 'Test Rezept', effort: 'Easy' }) }));
|
||||
});
|
||||
|
||||
it('returns fail(500) when API returns error', async () => {
|
||||
mockPost.mockResolvedValue({ error: { status: 500 } });
|
||||
const result = await actions.create({
|
||||
request: { formData: async () => makeFormData() },
|
||||
fetch: vi.fn()
|
||||
} as any);
|
||||
expect(result.status).toBe(500);
|
||||
});
|
||||
});
|
||||
45
frontend/src/routes/(app)/recipes/page.server.test.ts
Normal file
45
frontend/src/routes/(app)/recipes/page.server.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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 })
|
||||
}));
|
||||
|
||||
describe('recipe library page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockRecipes = [
|
||||
{ id: 'r1', name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' }
|
||||
];
|
||||
|
||||
it('fetches recipes from GET /v1/recipes', async () => {
|
||||
mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined });
|
||||
await load({ fetch: vi.fn() } as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object));
|
||||
});
|
||||
|
||||
it('returns recipes in data', async () => {
|
||||
mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.recipes).toHaveLength(2);
|
||||
expect(result.recipes[0].name).toBe('Spaghetti');
|
||||
});
|
||||
|
||||
it('returns empty array when API fails', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.recipes).toEqual([]);
|
||||
});
|
||||
});
|
||||
99
frontend/src/routes/(app)/recipes/page.test.ts
Normal file
99
frontend/src/routes/(app)/recipes/page.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import Page from './+page.svelte';
|
||||
|
||||
const mockData = {
|
||||
recipes: [
|
||||
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
|
||||
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
|
||||
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' }
|
||||
]
|
||||
};
|
||||
|
||||
describe('recipe library page', () => {
|
||||
it('renders all recipe cards initially', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Gemüsesuppe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the page title', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(document.title).toBe('Rezepte — Mealplan');
|
||||
});
|
||||
|
||||
it('renders a link to add a new recipe', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i });
|
||||
expect(addLink).toHaveAttribute('href', '/recipes/new');
|
||||
});
|
||||
|
||||
it('filters recipes by search term', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/suchen/i);
|
||||
await user.type(searchInput, 'Curry');
|
||||
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters recipes by effort chip', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mittel' }));
|
||||
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no recipes match search', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/suchen/i);
|
||||
await user.type(searchInput, 'xyznotexist');
|
||||
|
||||
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders filter chips', () => {
|
||||
render(Page, { props: { data: mockData } });
|
||||
expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state page when no recipes at all', () => {
|
||||
render(Page, { props: { data: { recipes: [] } } });
|
||||
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies search and effort filter together', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Leicht' }));
|
||||
const searchInput = screen.getByPlaceholderText(/suchen/i);
|
||||
await user.type(searchInput, 'Gemüse');
|
||||
|
||||
expect(screen.getByText('Gemüsesuppe')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Chicken Curry')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets to all recipes when Alle chip is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(Page, { props: { data: mockData } });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mittel' }));
|
||||
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Alle' }));
|
||||
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user