From e3f8d8ad73dc2914f7348a1bff704863b9032196 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:01:17 +0200 Subject: [PATCH 1/2] 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 --- frontend/src/lib/planner/DayMealCard.svelte | 76 ++++ frontend/src/lib/planner/DayMealCard.test.ts | 45 +++ .../src/lib/planner/VarietyScoreCard.svelte | 62 ++++ .../src/lib/planner/VarietyScoreCard.test.ts | 56 +++ frontend/src/lib/planner/WeekStrip.svelte | 70 ++++ frontend/src/lib/planner/WeekStrip.test.ts | 59 +++ frontend/src/lib/planner/week.ts | 88 +++++ .../src/routes/(app)/planner/+page.server.ts | 45 +++ .../src/routes/(app)/planner/+page.svelte | 346 +++++++++++++++++- .../routes/(app)/planner/page.server.test.ts | 130 +++++++ 10 files changed, 976 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/planner/DayMealCard.svelte create mode 100644 frontend/src/lib/planner/DayMealCard.test.ts create mode 100644 frontend/src/lib/planner/VarietyScoreCard.svelte create mode 100644 frontend/src/lib/planner/VarietyScoreCard.test.ts create mode 100644 frontend/src/lib/planner/WeekStrip.svelte create mode 100644 frontend/src/lib/planner/WeekStrip.test.ts create mode 100644 frontend/src/lib/planner/week.ts create mode 100644 frontend/src/routes/(app)/planner/+page.server.ts create mode 100644 frontend/src/routes/(app)/planner/page.server.test.ts diff --git a/frontend/src/lib/planner/DayMealCard.svelte b/frontend/src/lib/planner/DayMealCard.svelte new file mode 100644 index 0000000..600dcf6 --- /dev/null +++ b/frontend/src/lib/planner/DayMealCard.svelte @@ -0,0 +1,76 @@ + + +
+ {#if slot.recipe} +

+ {slot.recipe.name} +

+ {#if metadata} +

{metadata}

+ {/if} + + {#if !readonly} +
+ + Jetzt kochen + + +
+ {/if} + {:else} +

Kein Gericht geplant

+ {#if !readonly} + + + Gericht hinzufügen + + {/if} + {/if} +
diff --git a/frontend/src/lib/planner/DayMealCard.test.ts b/frontend/src/lib/planner/DayMealCard.test.ts new file mode 100644 index 0000000..0bfe2fe --- /dev/null +++ b/frontend/src/lib/planner/DayMealCard.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/planner/VarietyScoreCard.svelte b/frontend/src/lib/planner/VarietyScoreCard.svelte new file mode 100644 index 0000000..27dc086 --- /dev/null +++ b/frontend/src/lib/planner/VarietyScoreCard.svelte @@ -0,0 +1,62 @@ + + +
+
+ + {score} + + /10 + Abwechslungs-Score +
+ + +
+
+
+ + + {#if ingredientOverlaps.length > 0} +
    + {#each ingredientOverlaps as overlap} +
  • + ⚠ {overlap.ingredientName} in {overlap.days?.length ?? 0} Mahlzeiten +
  • + {/each} +
+ {/if} + + {#if showReviewLink} + + Variety überprüfen → + + {/if} +
diff --git a/frontend/src/lib/planner/VarietyScoreCard.test.ts b/frontend/src/lib/planner/VarietyScoreCard.test.ts new file mode 100644 index 0000000..ec86cac --- /dev/null +++ b/frontend/src/lib/planner/VarietyScoreCard.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/planner/WeekStrip.svelte b/frontend/src/lib/planner/WeekStrip.svelte new file mode 100644 index 0000000..c2d53b5 --- /dev/null +++ b/frontend/src/lib/planner/WeekStrip.svelte @@ -0,0 +1,70 @@ + + +
+ {#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')} + + + {/each} +
diff --git a/frontend/src/lib/planner/WeekStrip.test.ts b/frontend/src/lib/planner/WeekStrip.test.ts new file mode 100644 index 0000000..eaea5e1 --- /dev/null +++ b/frontend/src/lib/planner/WeekStrip.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/planner/week.ts b/frontend/src/lib/planner/week.ts new file mode 100644 index 0000000..205d2ac --- /dev/null +++ b/frontend/src/lib/planner/week.ts @@ -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}`; +} diff --git a/frontend/src/routes/(app)/planner/+page.server.ts b/frontend/src/routes/(app)/planner/+page.server.ts new file mode 100644 index 0000000..8fe4060 --- /dev/null +++ b/frontend/src/routes/(app)/planner/+page.server.ts @@ -0,0 +1,45 @@ +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 }) => { + const formData = await request.formData(); + const weekStart = formData.get('weekStart') as string; + + 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 }; + } +}; diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index dbb8271..395d142 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -1 +1,345 @@ -

Planer

+ + + +
+ +
+

Diese Woche

+
+ + + {#if isPlanner} + + + Gericht + + {/if} +
+
+ + + {#if varietyScore} +
+ +
+ {/if} + + +
+ +
+ + +
+

+ {formatDayLabel(selectedDay)} +

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

+ Restliche Woche +

+
+ {#each remainingSlots.filter((s: any) => s.recipe) as slot} + + {/each} +
+
+ {/if} + + + {#if !weekPlan} +
+

Noch kein Wochenplan für diese Woche.

+ {#if isPlanner} +
+ + +
+ {/if} +
+ {/if} +
+ + + diff --git a/frontend/src/routes/(app)/planner/page.server.test.ts b/frontend/src/routes/(app)/planner/page.server.test.ts new file mode 100644 index 0000000..0846c56 --- /dev/null +++ b/frontend/src/routes/(app)/planner/page.server.test.ts @@ -0,0 +1,130 @@ +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 } + }); + 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 } + }); + expect(result).toEqual({ success: false, error: expect.any(String) }); + }); +}); From 5d2bb9e84e5764a6c83d94b41ea64c5dffceb5d3 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:07:47 +0200 Subject: [PATCH 2/2] fix(planner): address all PR review blockers - Fix logic bug `{#if !isPlanner === false}` - view/cook buttons now visible for all roles, swap only for planner - Convert Tauschen from dead button to link with suggestions href - Add week.ts unit tests (23 tests covering getWeekStart Sunday edge case, prevWeek/nextWeek, weekDays, isToday, formatWeekRange) - Fix isToday to use UTC consistently (.toISOString().slice(0,10)) instead of local date - Add server-side role guard to createPlan action (403 for members) - Add weekStart format validation in createPlan action - Add isSelected prop to DayMealCard with green treatment - Make variety banner sticky on mobile (always visible per spec) - Add day name abbreviation above date badge in desktop column headers - Remove placeholder Navigation text from desktop sidebar - Add aria-label to desktop empty tile buttons - Add variety score partial failure test, multiple overlaps test, WeekStrip today+selected test Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/planner/DayMealCard.svelte | 22 ++- frontend/src/lib/planner/DayMealCard.test.ts | 26 +++- .../src/lib/planner/VarietyScoreCard.test.ts | 17 ++ frontend/src/lib/planner/WeekStrip.test.ts | 7 + frontend/src/lib/planner/week.test.ts | 146 ++++++++++++++++++ frontend/src/lib/planner/week.ts | 6 +- .../src/routes/(app)/planner/+page.server.ts | 12 +- .../src/routes/(app)/planner/+page.svelte | 90 +++++------ .../routes/(app)/planner/page.server.test.ts | 69 ++++++++- 9 files changed, 336 insertions(+), 59 deletions(-) create mode 100644 frontend/src/lib/planner/week.test.ts 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}