diff --git a/frontend/src/lib/planner/DayMealCard.svelte b/frontend/src/lib/planner/DayMealCard.svelte index eeaed6a..fb03bdf 100644 --- a/frontend/src/lib/planner/DayMealCard.svelte +++ b/frontend/src/lib/planner/DayMealCard.svelte @@ -16,12 +16,14 @@ slot, isToday = false, isSelected = false, - readonly = false + readonly = false, + onaddrecipe }: { slot: Slot; isToday?: boolean; isSelected?: boolean; readonly?: boolean; + onaddrecipe?: () => void; } = $props(); let metadata = $derived( @@ -64,23 +66,27 @@ > Jetzt kochen - - Tauschen - + {#if onaddrecipe} + + {/if} {/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 index dbac4db..cafa53a 100644 --- a/frontend/src/lib/planner/DayMealCard.test.ts +++ b/frontend/src/lib/planner/DayMealCard.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; import DayMealCard from './DayMealCard.svelte'; const slot = { @@ -14,22 +15,29 @@ describe('DayMealCard', () => { expect(screen.getByText('Pasta Bolognese')).toBeTruthy(); }); - it('shows Cook now and Tauschen links when not readonly', () => { - render(DayMealCard, { props: { slot, isToday: false, readonly: false } }); + it('shows Jetzt kochen link and Tauschen button when not readonly and onaddrecipe provided', () => { + render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe: vi.fn() } }); expect(screen.getByRole('link', { name: /Jetzt kochen/i })).toBeTruthy(); - expect(screen.getByRole('link', { name: /Tauschen/i })).toBeTruthy(); + expect(screen.getByRole('button', { name: /Tauschen/i })).toBeTruthy(); }); - it('Tauschen link navigates to suggestions for the slot day', () => { + it('Tauschen button calls onaddrecipe when clicked', async () => { + const onaddrecipe = vi.fn(); + const user = userEvent.setup(); + render(DayMealCard, { props: { slot, isToday: false, readonly: false, onaddrecipe } }); + await user.click(screen.getByRole('button', { name: /Tauschen/i })); + expect(onaddrecipe).toHaveBeenCalledOnce(); + }); + + it('hides Tauschen button when onaddrecipe not provided', () => { render(DayMealCard, { props: { slot, isToday: false, readonly: false } }); - const link = screen.getByRole('link', { name: /Tauschen/i }); - expect(link.getAttribute('href')).toContain('2026-03-30'); + expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull(); }); it('hides action links when readonly', () => { - render(DayMealCard, { props: { slot, isToday: false, readonly: true } }); + render(DayMealCard, { props: { slot, isToday: false, readonly: true, onaddrecipe: vi.fn() } }); expect(screen.queryByRole('link', { name: /Jetzt kochen/i })).toBeNull(); - expect(screen.queryByRole('link', { name: /Tauschen/i })).toBeNull(); + expect(screen.queryByRole('button', { name: /Tauschen/i })).toBeNull(); }); it('applies today styling when isToday is true', () => { @@ -55,9 +63,22 @@ describe('DayMealCard', () => { expect(screen.getByText(/Easy/)).toBeTruthy(); }); - it('empty state shows add link with suggestions href', () => { + it('empty state shows add button when onaddrecipe provided', () => { + const onaddrecipe = vi.fn(); + render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } }); + expect(screen.getByRole('button', { name: /Gericht hinzufügen/i })).toBeTruthy(); + }); + + it('add button calls onaddrecipe when clicked', async () => { + const onaddrecipe = vi.fn(); + const user = userEvent.setup(); + render(DayMealCard, { props: { slot: { id: 's2', slotDate: '2026-03-31', recipe: null }, isToday: false, readonly: false, onaddrecipe } }); + await user.click(screen.getByRole('button', { name: /Gericht hinzufügen/i })); + expect(onaddrecipe).toHaveBeenCalledOnce(); + }); + + it('empty state hides add button when onaddrecipe not provided', () => { 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'); + expect(screen.queryByRole('button', { name: /Gericht hinzufügen/i })).toBeNull(); }); }); diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index 68f8d1b..aa997e7 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -205,6 +205,7 @@ isToday={selectedDay === today} isSelected={true} readonly={!isPlanner} + onaddrecipe={isPlanner ? () => (pickerOpen = true) : undefined} /> diff --git a/frontend/src/routes/(app)/planner/suggestions/+page.server.ts b/frontend/src/routes/(app)/planner/suggestions/+page.server.ts deleted file mode 100644 index 57e437d..0000000 --- a/frontend/src/routes/(app)/planner/suggestions/+page.server.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { PageServerLoad, Actions } from './$types'; -import { redirect } from '@sveltejs/kit'; -import { apiClient } from '$lib/server/api'; -import { getWeekStart } from '$lib/planner/week'; - -export const load: PageServerLoad = async ({ fetch, url, locals: _locals }) => { - const weekParam = url.searchParams.get('week'); - const weekStart = weekParam ?? getWeekStart(new Date()); - const selectedDay = url.searchParams.get('day') ?? weekStart; - - const api = apiClient(fetch); - - // Load the week plan for context (week-so-far display) - const { data: weekPlan, error: weekPlanError } = await api.GET('/v1/week-plans', { - params: { query: { weekStart } } - }); - - if (weekPlanError || !weekPlan?.id) { - return { weekPlan: null, suggestions: [], selectedDay, weekStart }; - } - - // Load variety-aware suggestions for the selected day - const { data: suggestionsData } = await api.GET('/v1/week-plans/{id}/suggestions', { - params: { path: { id: weekPlan.id }, query: { slotDate: selectedDay } } - }); - - // Sort by simulatedScore descending (highest = best variety fit) - const suggestions = (suggestionsData?.suggestions ?? []).sort( - (a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0) - ); - - return { weekPlan, suggestions, selectedDay, weekStart }; -}; - -export const actions: Actions = { - pickSuggestion: async ({ fetch, request, locals }) => { - // Role guard: only planners may assign meals - if (locals.benutzer?.rolle !== 'planer') { - return { success: false, error: 'Keine Berechtigung.' }; - } - - const formData = await request.formData(); - const planId = formData.get('planId') as string; - const recipeId = formData.get('recipeId') as string; - const slotDate = formData.get('slotDate') as string; - const weekStart = formData.get('weekStart') as string; - - // Validate slotDate format - if (!slotDate || !/^\d{4}-\d{2}-\d{2}$/.test(slotDate)) { - return { success: false, error: 'Ungültiges Datum.' }; - } - - // Validate planId is non-empty - if (!planId) { - return { success: false, error: 'Fehlende Plan-ID.' }; - } - - // Validate recipeId is UUID-like format - if (!recipeId || !/^[0-9a-f-]{36}$/.test(recipeId)) { - return { success: false, error: 'Ungültige Rezept-ID.' }; - } - - const api = apiClient(fetch); - const { data, error } = await api.POST('/v1/week-plans/{id}/slots', { - params: { path: { id: planId } }, - body: { slotDate, recipeId } - }); - - if (error || !data) { - return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' }; - } - - // Redirect back to the planner after successful pick (spec: "returns to C1") - redirect(303, `/planner?week=${weekStart || slotDate.slice(0, 7) + '-01'}`); - } -}; diff --git a/frontend/src/routes/(app)/planner/suggestions/+page.svelte b/frontend/src/routes/(app)/planner/suggestions/+page.svelte deleted file mode 100644 index b0e92cd..0000000 --- a/frontend/src/routes/(app)/planner/suggestions/+page.svelte +++ /dev/null @@ -1,199 +0,0 @@ - - - - Gerichtsvorschläge — Mealplan - - - -
- -
- - ‹ - -

- Vorschläge für {formatDayLabel(selectedDay)} -

-
- - -
- -
- - -
- {#if rankedSuggestions.length === 0} -
-

- Keine Vorschläge verfügbar. -

- - Gesamte Rezeptbibliothek durchsuchen → - -
- {:else} -
- {#each rankedSuggestions as suggestion, i} - - {/each} -
- - - - {/if} -
-
- - - diff --git a/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts b/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts deleted file mode 100644 index 9ac3f9d..0000000 --- a/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -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('suggestions page — load', () => { - let load: any; - - const mockSuggestions = { - suggestions: [ - { - recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 }, - simulatedScore: 9.2 - }, - { - recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 }, - simulatedScore: 6.1 - } - ] - }; - - const mockWeekPlan = { - id: 'plan-1', - weekStart: '2026-03-30', - status: 'draft', - slots: [ - { id: 's1', slotDate: '2026-03-30', recipe: { id: 'r3', name: 'Pasta', effort: 'Easy' } } - ] - }; - - beforeEach(async () => { - mockGet.mockReset(); - mockPost.mockReset(); - vi.resetModules(); - const mod = await import('./+page.server'); - load = mod.load; - }); - - it('fetches suggestions for the given plan and day', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(mockGet).toHaveBeenCalledWith('/v1/week-plans/{id}/suggestions', expect.objectContaining({ - params: expect.objectContaining({ path: { id: 'plan-1' } }) - })); - }); - - it('returns suggestions list sorted by simulatedScore descending', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.suggestions[0].recipe.name).toBe('Pasta al Limone'); - expect(result.suggestions[1].recipe.name).toBe('Hühnchen Curry'); - }); - - it('returns the selectedDay from URL params', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.selectedDay).toBe('2026-04-01'); - }); - - it('returns empty suggestions when API fails', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 500 } }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.suggestions).toEqual([]); - }); - - it('returns week plan slots for context display', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.weekPlan).toBeDefined(); - expect(result.weekPlan.slots).toHaveLength(1); - }); - - it('returns null weekPlan and empty suggestions when week plan not found', async () => { - mockGet.mockResolvedValueOnce({ data: undefined, error: { status: 404 } }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30&day=2026-04-01'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.weekPlan).toBeNull(); - expect(result.suggestions).toEqual([]); - }); - - it('defaults day to weekStart when no day param provided', async () => { - mockGet.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); - mockGet.mockResolvedValueOnce({ data: mockSuggestions, error: undefined }); - const url = new URL('http://localhost/planner/suggestions?week=2026-03-30'); - const result = await load({ fetch: vi.fn(), url, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(result.selectedDay).toBe('2026-03-30'); - }); -}); - -describe('suggestions page — pickSuggestion action', () => { - let actions: any; - - beforeEach(async () => { - mockGet.mockReset(); - mockPost.mockReset(); - vi.resetModules(); - const mod = await import('./+page.server'); - actions = mod.actions; - }); - - it('adds a slot to the week plan via POST and redirects to planner', async () => { - mockPost.mockResolvedValue({ data: { id: 's-new', slotDate: '2026-04-01', recipe: {} }, error: undefined }); - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - try { - await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect.unreachable(); - } catch (e: any) { - expect(e.status).toBe(303); - expect(e.location).toBe('/planner?week=2026-03-30'); - } - expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({ - params: { path: { id: 'plan-1' } }, - body: { slotDate: '2026-04-01', recipeId: '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f' } - })); - }); - - it('returns error when planId is missing', async () => { - const formData = new FormData(); - formData.set('planId', ''); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect(result).toEqual({ success: false, error: 'Fehlende Plan-ID.' }); - expect(mockPost).not.toHaveBeenCalled(); - }); - - it('returns error for invalid recipeId format', async () => { - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', 'not-a-uuid'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect(result).toEqual({ success: false, error: 'Ungültige Rezept-ID.' }); - expect(mockPost).not.toHaveBeenCalled(); - }); - - it('returns error when API fails', async () => { - mockPost.mockResolvedValue({ data: undefined, error: { message: 'Server error' } }); - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect(result).toEqual({ success: false, error: expect.any(String) }); - }); - - it('returns permission error for member role', async () => { - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', '2026-04-01'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'mitglied' } } - }); - expect(result).toEqual({ success: false, error: 'Keine Berechtigung.' }); - expect(mockPost).not.toHaveBeenCalled(); - }); - - it('returns error for invalid slotDate format', async () => { - const formData = new FormData(); - formData.set('planId', 'plan-1'); - formData.set('recipeId', '1a2b3c4d-1234-1234-1234-1a2b3c4d5e6f'); - formData.set('slotDate', 'not-a-date'); - formData.set('weekStart', '2026-03-30'); - const result = await actions.pickSuggestion({ - fetch: vi.fn(), - request: { formData: async () => formData }, - locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } - }); - expect(result).toEqual({ success: false, error: expect.any(String) }); - expect(mockPost).not.toHaveBeenCalled(); - }); -});