diff --git a/frontend/src/lib/planner/RecipePicker.svelte b/frontend/src/lib/planner/RecipePicker.svelte new file mode 100644 index 0000000..f629ee0 --- /dev/null +++ b/frontend/src/lib/planner/RecipePicker.svelte @@ -0,0 +1,168 @@ + + +
+ +
+

+ Rezept wählen +

+

+ {dateLabel} +

+
+ + +
+ +
+ + + {#if suggestions.length > 0} +
+ Empfohlen · Beste Abwechslung +
+ + {#each suggestions as suggestion (suggestion.recipe.id)} + {@const delta = suggestion.simulatedScore - currentVarietyScore} + {@const meta = recipeMetadata(suggestion.recipe)} +
+
+

+ {suggestion.recipe.name} +

+ {#if meta} +

+ {meta} +

+ {/if} + {#if delta > 0} + + ↑ +{delta.toFixed(0)} Punkte + + {:else} + + ⚠ Variationskonflikt + + {/if} +
+ +
+ {/each} + {/if} + + +
+ Alle Rezepte +
+ + {#if filteredRecipes.length === 0} +

+ Keine Treffer +

+ {:else} + {#each filteredRecipes as recipe (recipe.id)} + {@const meta = recipeMetadata(recipe)} +
+
+

+ {recipe.name} +

+ {#if meta} +

+ {meta} +

+ {/if} +
+ +
+ {/each} + {/if} +
diff --git a/frontend/src/lib/planner/RecipePicker.test.ts b/frontend/src/lib/planner/RecipePicker.test.ts new file mode 100644 index 0000000..4ef320b --- /dev/null +++ b/frontend/src/lib/planner/RecipePicker.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import RecipePicker from './RecipePicker.svelte'; + +const suggestions = [ + { recipe: { id: 's1', name: 'Lachsfilet', effort: 'easy', cookTimeMin: 25 }, simulatedScore: 9.5 }, + { recipe: { id: 's2', name: 'Hähnchen-Curry', effort: 'easy', cookTimeMin: 35 }, simulatedScore: 6.0 } +]; + +const allRecipes = [ + { id: 'r1', name: 'Beef Bourguignon', effort: 'hard', cookTimeMin: 150 }, + { id: 'r2', name: 'Spaghetti Carbonara', effort: 'easy', cookTimeMin: 20 }, + { id: 'r3', name: 'Tomatensuppe', effort: 'easy', cookTimeMin: 30 } +]; + +const baseProps = { + planId: 'plan-1', + date: '2026-04-05', + dateLabel: 'Samstag, 5. April', + currentVarietyScore: 7.5, + suggestions, + allRecipes, + onpick: vi.fn() +}; + +describe('RecipePicker', () => { + it('shows date label in header', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText('Samstag, 5. April')).toBeTruthy(); + }); + + it('shows Empfohlen section', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText(/Empfohlen/i)).toBeTruthy(); + }); + + it('shows all suggestion recipe names', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText('Lachsfilet')).toBeTruthy(); + expect(screen.getByText('Hähnchen-Curry')).toBeTruthy(); + }); + + it('shows green badge for suggestions with positive delta', () => { + render(RecipePicker, { props: baseProps }); + // Lachsfilet: simulatedScore 9.5 - currentVarietyScore 7.5 = +2 → green badge + const badge = screen.getByTestId('badge-s1'); + expect(badge.getAttribute('data-type')).toBe('good'); + }); + + it('shows yellow badge for suggestions with zero or negative delta', () => { + render(RecipePicker, { props: baseProps }); + // Hähnchen-Curry: 6.0 - 7.5 = -1.5 → yellow badge + const badge = screen.getByTestId('badge-s2'); + expect(badge.getAttribute('data-type')).toBe('warning'); + }); + + it('shows Alle Rezepte section', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText(/Alle Rezepte/i)).toBeTruthy(); + }); + + it('shows all recipe names in Alle Rezepte', () => { + render(RecipePicker, { props: baseProps }); + expect(screen.getByText('Beef Bourguignon')).toBeTruthy(); + expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy(); + expect(screen.getByText('Tomatensuppe')).toBeTruthy(); + }); + + it('filters recipes by search query', async () => { + render(RecipePicker, { props: baseProps }); + const input = screen.getByRole('searchbox'); + await userEvent.type(input, 'Spaghetti'); + expect(screen.queryByText('Beef Bourguignon')).toBeNull(); + expect(screen.getByText('Spaghetti Carbonara')).toBeTruthy(); + }); + + it('calls onpick with recipeId and name when Wählen clicked for suggestion', async () => { + const onpick = vi.fn(); + render(RecipePicker, { props: { ...baseProps, onpick } }); + const buttons = screen.getAllByRole('button', { name: /Wählen/i }); + await userEvent.click(buttons[0]); + expect(onpick).toHaveBeenCalledWith('s1', 'Lachsfilet'); + }); + + it('calls onpick when Wählen clicked for all-recipes item', async () => { + const onpick = vi.fn(); + render(RecipePicker, { props: { ...baseProps, onpick } }); + const buttons = screen.getAllByRole('button', { name: /Wählen/i }); + // First 2 are suggestions, rest are allRecipes + await userEvent.click(buttons[2]); + expect(onpick).toHaveBeenCalledWith('r1', 'Beef Bourguignon'); + }); + + it('shows empty state when search has no results', async () => { + render(RecipePicker, { props: baseProps }); + const input = screen.getByRole('searchbox'); + await userEvent.type(input, 'xyznotfound'); + expect(screen.getByText(/Keine Treffer/i)).toBeTruthy(); + }); +}); diff --git a/frontend/src/routes/(app)/planner/+server.ts b/frontend/src/routes/(app)/planner/+server.ts new file mode 100644 index 0000000..910efc5 --- /dev/null +++ b/frontend/src/routes/(app)/planner/+server.ts @@ -0,0 +1,24 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { apiClient } from '$lib/server/api'; + +// GET /planner?planId=&date= — returns suggestions JSON for C4 recipe picker +export const GET: RequestHandler = async ({ fetch, url }) => { + const planId = url.searchParams.get('planId'); + const date = url.searchParams.get('date'); + + if (!planId || !date) { + return json({ suggestions: [] }); + } + + const api = apiClient(fetch); + const { data } = await api.GET('/v1/week-plans/{id}/suggestions', { + params: { path: { id: planId }, query: { slotDate: date } } + }); + + const suggestions = (data?.suggestions ?? []).sort( + (a: any, b: any) => (b.simulatedScore ?? 0) - (a.simulatedScore ?? 0) + ); + + return json({ suggestions }); +};