diff --git a/frontend/src/lib/planner/DayMealCard.svelte b/frontend/src/lib/planner/DayMealCard.svelte index 600dcf6..eeaed6a 100644 --- a/frontend/src/lib/planner/DayMealCard.svelte +++ b/frontend/src/lib/planner/DayMealCard.svelte @@ -15,10 +15,12 @@ let { slot, isToday = false, + isSelected = false, readonly = false }: { slot: Slot; isToday?: boolean; + isSelected?: boolean; readonly?: boolean; } = $props(); @@ -30,13 +32,21 @@ .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)]' + );
{#if slot.recipe}

@@ -54,19 +64,19 @@ > Jetzt kochen - +

{/if} {:else}

Kein Gericht geplant

{#if !readonly} + Gericht hinzufügen diff --git a/frontend/src/lib/planner/DayMealCard.test.ts b/frontend/src/lib/planner/DayMealCard.test.ts index 0bfe2fe..dbac4db 100644 --- a/frontend/src/lib/planner/DayMealCard.test.ts +++ b/frontend/src/lib/planner/DayMealCard.test.ts @@ -14,16 +14,22 @@ describe('DayMealCard', () => { expect(screen.getByText('Pasta Bolognese')).toBeTruthy(); }); - it('shows Cook now and Swap buttons when not readonly', () => { + 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('button', { name: /Tauschen/i })).toBeTruthy(); + expect(screen.getByRole('link', { name: /Tauschen/i })).toBeTruthy(); }); - it('hides action buttons when readonly', () => { + 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('button', { name: /Tauschen/i })).toBeNull(); + expect(screen.queryByRole('link', { name: /Tauschen/i })).toBeNull(); }); it('applies today styling when isToday is true', () => { @@ -32,6 +38,12 @@ describe('DayMealCard', () => { 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(); @@ -42,4 +54,10 @@ describe('DayMealCard', () => { 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'); + }); }); diff --git a/frontend/src/lib/planner/VarietyScoreCard.test.ts b/frontend/src/lib/planner/VarietyScoreCard.test.ts index ec86cac..2bfbdd1 100644 --- a/frontend/src/lib/planner/VarietyScoreCard.test.ts +++ b/frontend/src/lib/planner/VarietyScoreCard.test.ts @@ -53,4 +53,21 @@ describe('VarietyScoreCard', () => { 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(); + }); }); diff --git a/frontend/src/lib/planner/WeekStrip.test.ts b/frontend/src/lib/planner/WeekStrip.test.ts index eaea5e1..0fd05cc 100644 --- a/frontend/src/lib/planner/WeekStrip.test.ts +++ b/frontend/src/lib/planner/WeekStrip.test.ts @@ -41,6 +41,13 @@ describe('WeekStrip', () => { 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, { diff --git a/frontend/src/lib/planner/week.test.ts b/frontend/src/lib/planner/week.test.ts new file mode 100644 index 0000000..0b919e6 --- /dev/null +++ b/frontend/src/lib/planner/week.test.ts @@ -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(','); + }); +}); diff --git a/frontend/src/lib/planner/week.ts b/frontend/src/lib/planner/week.ts index 205d2ac..69c8b76 100644 --- a/frontend/src/lib/planner/week.ts +++ b/frontend/src/lib/planner/week.ts @@ -67,11 +67,11 @@ export function formatDayFull(dateStr: string): string { } /** - * Returns true if dateStr is today (local time). + * 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 today = new Date(); - const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + const todayStr = new Date().toISOString().slice(0, 10); return dateStr === todayStr; } diff --git a/frontend/src/routes/(app)/planner/+page.server.ts b/frontend/src/routes/(app)/planner/+page.server.ts index 8fe4060..fee0c24 100644 --- a/frontend/src/routes/(app)/planner/+page.server.ts +++ b/frontend/src/routes/(app)/planner/+page.server.ts @@ -27,10 +27,20 @@ export const load: PageServerLoad = async ({ fetch, url }) => { }; export const actions: Actions = { - createPlan: async ({ fetch, request }) => { + 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 } diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index 395d142..373e481 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -3,17 +3,14 @@ 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, formatWeekRange, isToday } from '$lib/planner/week'; + 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; - const todayStr = getWeekStart(new Date()); - const today = (() => { - const d = new Date(); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - })(); + // 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); @@ -35,7 +32,8 @@ }); let selectedSlot = $derived(slotMap[selectedDay] ?? { id: null, slotDate: selectedDay, recipe: null }); - let remainingSlots = $derived(days.filter(d => d > selectedDay).map((d: string) => slotMap[d] ?? { id: null, slotDate: d, 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'); @@ -88,9 +86,9 @@ - + {#if varietyScore} -
+
{formatDayLabel(selectedDay)}

- +
- {#if remainingSlots.filter((s: any) => s.recipe).length > 0} + {#if remainingSlotsWithMeal.length > 0}

Restliche Woche

- {#each remainingSlots.filter((s: any) => s.recipe) as slot} + {#each remainingSlotsWithMeal as slot}