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} + + {/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) }); + }); +});