diff --git a/frontend/src/lib/planner/SuggestionCard.svelte b/frontend/src/lib/planner/SuggestionCard.svelte index abc5fba..3d3f85e 100644 --- a/frontend/src/lib/planner/SuggestionCard.svelte +++ b/frontend/src/lib/planner/SuggestionCard.svelte @@ -39,13 +39,13 @@
-
+
{rank}
-

+

{suggestion.recipe?.name ?? 'Unbekanntes Rezept'}

{#if metadata} diff --git a/frontend/src/lib/planner/SuggestionContextBanner.svelte b/frontend/src/lib/planner/SuggestionContextBanner.svelte index c74e32e..57b4d31 100644 --- a/frontend/src/lib/planner/SuggestionContextBanner.svelte +++ b/frontend/src/lib/planner/SuggestionContextBanner.svelte @@ -27,7 +27,7 @@ weekPlan: WeekPlan | null; } = $props(); - let expanded = $state(true); + let expanded = $state(false); let slotsWithMeal = $derived( (weekPlan?.slots ?? []).filter((s) => s.recipe && s.slotDate !== selectedDay) diff --git a/frontend/src/lib/planner/SuggestionContextBanner.test.ts b/frontend/src/lib/planner/SuggestionContextBanner.test.ts index c076f29..ee78d60 100644 --- a/frontend/src/lib/planner/SuggestionContextBanner.test.ts +++ b/frontend/src/lib/planner/SuggestionContextBanner.test.ts @@ -18,21 +18,27 @@ describe('SuggestionContextBanner', () => { expect(screen.getByTestId('context-banner')).toBeTruthy(); }); - it('renders meals from the current week', () => { + it('renders meals from the current week after expanding', async () => { render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } }); + // Banner starts collapsed — expand it first + const toggle = screen.getByRole('button', { name: /Filter|einblenden/i }); + await fireEvent.click(toggle); expect(screen.getByText(/Pasta/)).toBeTruthy(); expect(screen.getByText(/Curry/)).toBeTruthy(); }); - it('shows/hides detail on toggle', async () => { + it('starts collapsed and expands on toggle', async () => { render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } }); - const toggle = screen.getByRole('button', { name: /Kontext|Filter|Details|ausblenden|einblenden/i }); - // Initially expanded or collapsed — toggling should change visibility const detail = screen.getByTestId('context-detail'); - const initiallyVisible = !detail.hasAttribute('hidden'); + // Initially collapsed + expect(detail.hasAttribute('hidden')).toBe(true); + const toggle = screen.getByRole('button', { name: /Filter|einblenden/i }); await fireEvent.click(toggle); - // After toggle the state changes - expect(detail.hasAttribute('hidden') || detail.getAttribute('aria-hidden') === 'true').toBe(initiallyVisible); + // After toggle: expanded + expect(detail.hasAttribute('hidden')).toBe(false); + await fireEvent.click(toggle); + // After second toggle: collapsed again + expect(detail.hasAttribute('hidden')).toBe(true); }); it('renders with no slots gracefully', () => { diff --git a/frontend/src/routes/(app)/planner/suggestions/+page.server.ts b/frontend/src/routes/(app)/planner/suggestions/+page.server.ts index a0b515b..57e437d 100644 --- a/frontend/src/routes/(app)/planner/suggestions/+page.server.ts +++ b/frontend/src/routes/(app)/planner/suggestions/+page.server.ts @@ -1,4 +1,5 @@ import type { PageServerLoad, Actions } from './$types'; +import { redirect } from '@sveltejs/kit'; import { apiClient } from '$lib/server/api'; import { getWeekStart } from '$lib/planner/week'; @@ -42,14 +43,21 @@ export const actions: Actions = { 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.' }; } - if (!planId || !recipeId) { - return { success: false, error: 'Fehlende Pflichtfelder.' }; + // 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); @@ -62,6 +70,7 @@ export const actions: Actions = { return { success: false, error: 'Gericht konnte nicht hinzugefügt werden.' }; } - return { success: true }; + // 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 index 1c13b08..b0e92cd 100644 --- a/frontend/src/routes/(app)/planner/suggestions/+page.svelte +++ b/frontend/src/routes/(app)/planner/suggestions/+page.svelte @@ -10,7 +10,8 @@ let selectedDay = $derived(data.selectedDay); let weekStart = $derived(data.weekStart); - // Add rank and derive reasoning from simulatedScore for display + // Add rank and derive reasoning from simulatedScore for display. + // TODO: replace hardcoded threshold (7.5) with API-provided reasoning once backend supports it. let rankedSuggestions = $derived( suggestions.map((s: any, i: number) => ({ ...s, diff --git a/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts b/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts index d9ada96..9ac3f9d 100644 --- a/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts +++ b/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts @@ -115,11 +115,34 @@ describe('suggestions page — pickSuggestion action', () => { actions = mod.actions; }); - it('adds a slot to the week plan via POST', async () => { + 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', 'r1'); + 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({ @@ -127,18 +150,30 @@ describe('suggestions page — pickSuggestion action', () => { request: { formData: async () => formData }, locals: { benutzer: { id: 'u1', name: 'Test', rolle: 'planer' } } }); - expect(mockPost).toHaveBeenCalledWith('/v1/week-plans/{id}/slots', expect.objectContaining({ - params: { path: { id: 'plan-1' } }, - body: { slotDate: '2026-04-01', recipeId: 'r1' } - })); - expect(result).toEqual({ success: true }); + 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', 'r1'); + 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({ @@ -152,7 +187,7 @@ describe('suggestions page — pickSuggestion action', () => { it('returns permission error for member role', async () => { const formData = new FormData(); formData.set('planId', 'plan-1'); - formData.set('recipeId', 'r1'); + 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({ @@ -167,7 +202,7 @@ describe('suggestions page — pickSuggestion action', () => { it('returns error for invalid slotDate format', async () => { const formData = new FormData(); formData.set('planId', 'plan-1'); - formData.set('recipeId', 'r1'); + 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({