feat(planner): C1 — Weekly planner home screen #39
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
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}`;
|
||||||
|
}
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user