feat(planner): implement C1 weekly planner home screen (#26)

Three-breakpoint layout (mobile/tablet/desktop) with VarietyScoreCard,
WeekStrip, DayMealCard components. Server loads week plan and variety
score via API; read-only role behavior derived from benutzer.rolle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 11:01:17 +02:00
parent 0511a735a5
commit e3f8d8ad73
10 changed files with 976 additions and 1 deletions

View File

@@ -0,0 +1,76 @@
<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,
readonly = false
}: {
slot: Slot;
isToday?: boolean;
readonly?: boolean;
} = $props();
let metadata = $derived(
[
slot.recipe?.cookTimeMin != null ? `${slot.recipe.cookTimeMin} Min` : null,
slot.recipe?.effort ?? null
]
.filter(Boolean)
.join(' · ')
);
</script>
<div
data-testid="day-meal-card"
data-today={isToday}
class="rounded-[var(--radius-lg)] border-2 p-4 transition-colors
{isToday ? 'border-[var(--yellow)] bg-[var(--yellow-tint)]' : 'border-[var(--color-border)] bg-[var(--color-surface)]'}"
>
{#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>
<button
type="button"
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
</button>
</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"
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>

View File

@@ -0,0 +1,45 @@
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 Swap buttons when not readonly', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: false } });
expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Tauschen/i })).toBeTruthy();
});
it('hides action buttons when readonly', () => {
render(DayMealCard, { props: { slot, isToday: false, readonly: true } });
expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull();
expect(screen.queryByRole('button', { 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('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();
});
});

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

View File

@@ -0,0 +1,56 @@
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();
});
});

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

View File

@@ -0,0 +1,59 @@
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('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');
});
});

View 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 (local time).
*/
export function isToday(dateStr: string): boolean {
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
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}`;
}