From c94656d998d3521b59bedbdb08949a99ed8ec536 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 11:12:26 +0200 Subject: [PATCH] feat(suggestions): implement C2 meal suggestions screen (#27) Two-panel desktop layout (280px context panel + flex suggestions panel) and mobile layout (sticky header + collapsible context banner + ranked list). - SuggestionCard: rank number, recipe metadata, green/yellow reasoning badge, pick form - SuggestionContextBanner: collapsible week-so-far list + filter reasons - Server load: fetches week plan + variety-aware suggestions sorted by simulatedScore - pickSuggestion action: role guard, date validation, POST to /v1/week-plans/{id}/slots - Browse full library link passes selectFor+week params to recipe library (B1 integration point) Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/planner/SuggestionCard.svelte | 83 ++++++++ .../src/lib/planner/SuggestionCard.test.ts | 60 ++++++ .../planner/SuggestionContextBanner.svelte | 86 ++++++++ .../planner/SuggestionContextBanner.test.ts | 42 ++++ .../(app)/planner/suggestions/+page.server.ts | 67 ++++++ .../(app)/planner/suggestions/+page.svelte | 198 ++++++++++++++++++ .../planner/suggestions/page.server.test.ts | 181 ++++++++++++++++ 7 files changed, 717 insertions(+) create mode 100644 frontend/src/lib/planner/SuggestionCard.svelte create mode 100644 frontend/src/lib/planner/SuggestionCard.test.ts create mode 100644 frontend/src/lib/planner/SuggestionContextBanner.svelte create mode 100644 frontend/src/lib/planner/SuggestionContextBanner.test.ts create mode 100644 frontend/src/routes/(app)/planner/suggestions/+page.server.ts create mode 100644 frontend/src/routes/(app)/planner/suggestions/+page.svelte create mode 100644 frontend/src/routes/(app)/planner/suggestions/page.server.test.ts diff --git a/frontend/src/lib/planner/SuggestionCard.svelte b/frontend/src/lib/planner/SuggestionCard.svelte new file mode 100644 index 0000000..abc5fba --- /dev/null +++ b/frontend/src/lib/planner/SuggestionCard.svelte @@ -0,0 +1,83 @@ + + +
+ +
+ {rank} +
+ + +
+

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

+ {#if metadata} +

{metadata}

+ {/if} + + + {#if suggestion.reasoningType && suggestion.reasoningLabel} +
+ {suggestion.reasoningType === 'good' ? '✓' : '⚠'} {suggestion.reasoningLabel} +
+ {/if} +
+ + +
+ + + + + +
+
diff --git a/frontend/src/lib/planner/SuggestionCard.test.ts b/frontend/src/lib/planner/SuggestionCard.test.ts new file mode 100644 index 0000000..48ad994 --- /dev/null +++ b/frontend/src/lib/planner/SuggestionCard.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import SuggestionCard from './SuggestionCard.svelte'; + +const goodSuggestion = { + recipe: { id: 'r1', name: 'Pasta al Limone', effort: 'Easy', cookTimeMin: 25 }, + simulatedScore: 9.2, + reasoningType: 'good' as const, + reasoningLabel: 'Frisches Protein · Aufwandsbalance' +}; + +const warningSuggestion = { + recipe: { id: 'r2', name: 'Hühnchen Curry', effort: 'Medium', cookTimeMin: 45 }, + simulatedScore: 6.1, + reasoningType: 'warning' as const, + reasoningLabel: 'Hähnchen schon 2 Tage dabei' +}; + +describe('SuggestionCard', () => { + it('renders recipe name', () => { + render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); + expect(screen.getByText('Pasta al Limone')).toBeTruthy(); + }); + + it('renders rank number', () => { + render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); + expect(screen.getByText('1')).toBeTruthy(); + }); + + it('renders cook time and effort metadata', () => { + render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); + expect(screen.getByText(/25 Min/)).toBeTruthy(); + expect(screen.getByText(/Easy/)).toBeTruthy(); + }); + + it('renders green reasoning badge for good suggestions', () => { + render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); + const badge = screen.getByTestId('reasoning-badge'); + expect(badge.getAttribute('data-type')).toBe('good'); + expect(badge.textContent).toContain('Frisches Protein'); + }); + + it('renders yellow reasoning badge for warnings', () => { + render(SuggestionCard, { props: { suggestion: warningSuggestion, rank: 2, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); + const badge = screen.getByTestId('reasoning-badge'); + expect(badge.getAttribute('data-type')).toBe('warning'); + expect(badge.textContent).toContain('Hähnchen'); + }); + + it('renders a pick button/form', () => { + render(SuggestionCard, { props: { suggestion: goodSuggestion, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); + expect(screen.getByRole('button', { name: /Wählen/i })).toBeTruthy(); + }); + + it('card without reasoning renders without crashing', () => { + const noReasoning = { ...goodSuggestion, reasoningType: undefined, reasoningLabel: undefined }; + render(SuggestionCard, { props: { suggestion: noReasoning, rank: 1, planId: 'p1', slotDate: '2026-04-01', weekStart: '2026-03-30' } }); + expect(screen.getByText('Pasta al Limone')).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/planner/SuggestionContextBanner.svelte b/frontend/src/lib/planner/SuggestionContextBanner.svelte new file mode 100644 index 0000000..c74e32e --- /dev/null +++ b/frontend/src/lib/planner/SuggestionContextBanner.svelte @@ -0,0 +1,86 @@ + + +
+
+

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

+ +
+ +
+ {#if slotsWithMeal.length > 0} +
+

+ Diese Woche bisher +

+
    + {#each slotsWithMeal as slot} +
  • + {formatDayLabel(slot.slotDate!).split(',')[0]} + {slot.recipe?.name} +
  • + {/each} +
+
+ {:else} +

+ Noch keine Gerichte diese Woche geplant +

+ {/if} +
+
diff --git a/frontend/src/lib/planner/SuggestionContextBanner.test.ts b/frontend/src/lib/planner/SuggestionContextBanner.test.ts new file mode 100644 index 0000000..c076f29 --- /dev/null +++ b/frontend/src/lib/planner/SuggestionContextBanner.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import SuggestionContextBanner from './SuggestionContextBanner.svelte'; + +const weekPlan = { + id: 'plan-1', + weekStart: '2026-03-30', + 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: 'Hard' } } + ] +}; + +describe('SuggestionContextBanner', () => { + it('renders the selected day label', () => { + render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } }); + // Day label should be visible + expect(screen.getByTestId('context-banner')).toBeTruthy(); + }); + + it('renders meals from the current week', () => { + render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan } }); + expect(screen.getByText(/Pasta/)).toBeTruthy(); + expect(screen.getByText(/Curry/)).toBeTruthy(); + }); + + it('shows/hides detail 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'); + await fireEvent.click(toggle); + // After toggle the state changes + expect(detail.hasAttribute('hidden') || detail.getAttribute('aria-hidden') === 'true').toBe(initiallyVisible); + }); + + it('renders with no slots gracefully', () => { + render(SuggestionContextBanner, { props: { selectedDay: '2026-04-01', weekPlan: { ...weekPlan, slots: [] } } }); + expect(screen.getByTestId('context-banner')).toBeTruthy(); + }); +}); diff --git a/frontend/src/routes/(app)/planner/suggestions/+page.server.ts b/frontend/src/routes/(app)/planner/suggestions/+page.server.ts new file mode 100644 index 0000000..a0b515b --- /dev/null +++ b/frontend/src/routes/(app)/planner/suggestions/+page.server.ts @@ -0,0 +1,67 @@ +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, 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; + + // 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.' }; + } + + 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.' }; + } + + return { success: true }; + } +}; diff --git a/frontend/src/routes/(app)/planner/suggestions/+page.svelte b/frontend/src/routes/(app)/planner/suggestions/+page.svelte new file mode 100644 index 0000000..1c13b08 --- /dev/null +++ b/frontend/src/routes/(app)/planner/suggestions/+page.svelte @@ -0,0 +1,198 @@ + + + + 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 new file mode 100644 index 0000000..d9ada96 --- /dev/null +++ b/frontend/src/routes/(app)/planner/suggestions/page.server.test.ts @@ -0,0 +1,181 @@ +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', 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('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(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 }); + }); + + 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('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', 'r1'); + 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', 'r1'); + 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(); + }); +});