From 178c8886351a05f4b446a69e6f02cbb3b1a297f3 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Wed, 8 Apr 2026 23:09:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20add=20C6=20day-picker=20flow?= =?UTF-8?q?=20=E2=80=94=20week=20plan=20load=20+=20slot=20actions=20+=20Da?= =?UTF-8?q?yPicker=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeGrid.svelte | 4 +- .../src/routes/(app)/recipes/+page.server.ts | 84 ++++++-- .../src/routes/(app)/recipes/+page.svelte | 197 +++++++++++++++++- frontend/src/routes/(app)/recipes/+server.ts | 17 ++ .../routes/(app)/recipes/page.server.test.ts | 139 +++++++++++- .../src/routes/(app)/recipes/page.test.ts | 5 +- 6 files changed, 413 insertions(+), 33 deletions(-) create mode 100644 frontend/src/routes/(app)/recipes/+server.ts diff --git a/frontend/src/lib/recipes/RecipeGrid.svelte b/frontend/src/lib/recipes/RecipeGrid.svelte index 1c35c17..7c38f8b 100644 --- a/frontend/src/lib/recipes/RecipeGrid.svelte +++ b/frontend/src/lib/recipes/RecipeGrid.svelte @@ -2,13 +2,13 @@ import RecipeCard from './RecipeCard.svelte'; import type { RecipeSummary } from './types'; - let { recipes }: { recipes: RecipeSummary[] } = $props(); + let { recipes, onplan }: { recipes: RecipeSummary[]; onplan?: (recipeId: string, recipeName: string) => void } = $props(); {#if recipes.length > 0}
{#each recipes as recipe (recipe.id)} - + {/each}
{:else} diff --git a/frontend/src/routes/(app)/recipes/+page.server.ts b/frontend/src/routes/(app)/recipes/+page.server.ts index bff4ccb..3355fcc 100644 --- a/frontend/src/routes/(app)/recipes/+page.server.ts +++ b/frontend/src/routes/(app)/recipes/+page.server.ts @@ -1,21 +1,77 @@ -import type { PageServerLoad } from './$types'; +import type { PageServerLoad, Actions } from './$types'; import { apiClient } from '$lib/server/api'; +import { getWeekStart } from '$lib/planner/week'; export const load: PageServerLoad = async ({ fetch }) => { const api = apiClient(fetch); - const { data, error } = await api.GET('/v1/recipes', {}); + const weekStart = getWeekStart(new Date()); - if (error || !data?.data) { - return { recipes: [] }; - } + const [recipesResult, weekPlanResult] = await Promise.all([ + api.GET('/v1/recipes', {}), + api.GET('/v1/week-plans', { params: { query: { weekStart } } }) + ]); - return { - recipes: data.data.map((r) => ({ - id: r.id!, - name: r.name!, - cookTimeMin: r.cookTimeMin, - effort: r.effort, - heroImageUrl: r.heroImageUrl - })) - }; + const recipes = + recipesResult.error || !recipesResult.data?.data + ? [] + : recipesResult.data.data.map((r) => ({ + id: r.id!, + name: r.name!, + cookTimeMin: r.cookTimeMin, + effort: r.effort, + heroImageUrl: r.heroImageUrl + })); + + const activePlan = + weekPlanResult.error || !weekPlanResult.data?.id ? null : weekPlanResult.data; + + return { recipes, activePlan }; +}; + +export const actions: Actions = { + addSlot: async ({ fetch, request }) => { + const formData = await request.formData(); + const planId = formData.get('planId') as string; + const slotDate = formData.get('slotDate') as string; + const recipeId = formData.get('recipeId') as string; + + 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 }; + return { success: true, slot: data }; + }, + + updateSlot: async ({ fetch, request }) => { + const formData = await request.formData(); + const planId = formData.get('planId') as string; + const slotId = formData.get('slotId') as string; + const recipeId = formData.get('recipeId') as string; + + const api = apiClient(fetch); + const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', { + params: { path: { planId, slotId } }, + body: { recipeId } + }); + + if (error || !data) return { success: false }; + return { success: true, slot: data }; + }, + + deleteSlot: async ({ fetch, request }) => { + const formData = await request.formData(); + const planId = formData.get('planId') as string; + const slotId = formData.get('slotId') as string; + + const api = apiClient(fetch); + const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', { + params: { path: { planId, slotId } } + }); + + if (error) return { success: false }; + return { success: true }; + } }; diff --git a/frontend/src/routes/(app)/recipes/+page.svelte b/frontend/src/routes/(app)/recipes/+page.svelte index c4eb2e4..28e7139 100644 --- a/frontend/src/routes/(app)/recipes/+page.svelte +++ b/frontend/src/routes/(app)/recipes/+page.svelte @@ -1,10 +1,18 @@ @@ -30,18 +109,116 @@
-

Rezepte

- Rezept hinzufügen +

+ Rezepte +

+ + Rezept hinzufügen +
- + (activeFilter = f)} /> - +
+ + (pickerOpen = false)} height="55vh"> + {#if pickerPlan} + + {/if} + + + (undoVisible = false)} +/> + + + + + + + diff --git a/frontend/src/routes/(app)/recipes/+server.ts b/frontend/src/routes/(app)/recipes/+server.ts new file mode 100644 index 0000000..5f63da9 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { apiClient } from '$lib/server/api'; + +// GET /recipes?week=YYYY-MM-DD — returns week plan for DayPicker week navigation +export const GET: RequestHandler = async ({ fetch, url }) => { + const weekStart = url.searchParams.get('week'); + if (!weekStart) return json({ plan: null }); + + const api = apiClient(fetch); + const { data, error } = await api.GET('/v1/week-plans', { + params: { query: { weekStart } } + }); + + if (error || !data?.id) return json({ plan: null }); + return json({ plan: data }); +}; diff --git a/frontend/src/routes/(app)/recipes/page.server.test.ts b/frontend/src/routes/(app)/recipes/page.server.test.ts index 50c1358..0fe73d1 100644 --- a/frontend/src/routes/(app)/recipes/page.server.test.ts +++ b/frontend/src/routes/(app)/recipes/page.server.test.ts @@ -5,8 +5,15 @@ vi.mock('$env/dynamic/private', () => ({ })); const mockGet = vi.fn(); +const mockPost = vi.fn(); +const mockPatch = vi.fn(); +const mockDelete = vi.fn(); vi.mock('$lib/server/api', () => ({ - apiClient: () => ({ GET: mockGet }) + apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete }) +})); + +vi.mock('$lib/planner/week', () => ({ + getWeekStart: () => '2026-04-07' })); describe('recipe library page — load', () => { @@ -24,22 +31,144 @@ describe('recipe library page — load', () => { { id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' } ]; + const mockWeekPlan = { + id: 'plan-1', + weekStart: '2026-04-07', + slots: [{ id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } }] + }; + it('fetches recipes from GET /v1/recipes', async () => { - mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); await load({ fetch: vi.fn() } as any); expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object)); }); it('returns recipes in data', async () => { - mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined }); + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); const result = await load({ fetch: vi.fn() } as any); expect(result.recipes).toHaveLength(2); expect(result.recipes[0].name).toBe('Spaghetti'); }); - it('returns empty array when API fails', async () => { - mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } }); + it('returns empty array when recipes API fails', async () => { + mockGet + .mockResolvedValueOnce({ data: undefined, error: { status: 500 } }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); const result = await load({ fetch: vi.fn() } as any); expect(result.recipes).toEqual([]); }); + + it('fetches active week plan from GET /v1/week-plans', async () => { + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); + await load({ fetch: vi.fn() } as any); + expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.any(Object)); + }); + + it('returns activePlan with id and slots in data', async () => { + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: mockWeekPlan, error: undefined }); + const result = await load({ fetch: vi.fn() } as any); + expect(result.activePlan.id).toBe('plan-1'); + expect(result.activePlan.slots).toHaveLength(1); + }); + + it('returns null activePlan when week plan API fails', async () => { + mockGet + .mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined }) + .mockResolvedValueOnce({ data: undefined, error: { status: 404 } }); + const result = await load({ fetch: vi.fn() } as any); + expect(result.activePlan).toBeNull(); + }); +}); + +describe('recipe library page — actions', () => { + let actions: any; + + beforeEach(async () => { + mockGet.mockReset(); + mockPost.mockReset(); + mockPatch.mockReset(); + mockDelete.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + it('addSlot calls POST /v1/week-plans/{id}/slots and returns success', async () => { + const formData = new FormData(); + formData.set('planId', 'plan-1'); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', 'r1'); + mockPost.mockResolvedValue({ data: { id: 's1' }, error: undefined }); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockPost).toHaveBeenCalledWith( + '/v1/week-plans/{id}/slots', + expect.objectContaining({ + params: { path: { id: 'plan-1' } }, + body: { slotDate: '2026-04-01', recipeId: 'r1' } + }) + ); + expect(result.success).toBe(true); + }); + + it('addSlot returns error when API fails', async () => { + const formData = new FormData(); + formData.set('planId', 'plan-1'); + formData.set('slotDate', '2026-04-01'); + formData.set('recipeId', 'r1'); + mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } }); + const result = await actions.addSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(result.success).toBe(false); + }); + + it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { + const formData = new FormData(); + formData.set('planId', 'plan-1'); + formData.set('slotId', 's1'); + formData.set('recipeId', 'r1'); + mockPatch.mockResolvedValue({ data: { id: 's1' }, error: undefined }); + const result = await actions.updateSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockPatch).toHaveBeenCalledWith( + '/v1/week-plans/{planId}/slots/{slotId}', + expect.objectContaining({ + params: { path: { planId: 'plan-1', slotId: 's1' } }, + body: { recipeId: 'r1' } + }) + ); + expect(result.success).toBe(true); + }); + + it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => { + const formData = new FormData(); + formData.set('planId', 'plan-1'); + formData.set('slotId', 's1'); + mockDelete.mockResolvedValue({ error: undefined }); + const result = await actions.deleteSlot({ + fetch: vi.fn(), + request: { formData: async () => formData } + } as any); + expect(mockDelete).toHaveBeenCalledWith( + '/v1/week-plans/{planId}/slots/{slotId}', + expect.objectContaining({ + params: { path: { planId: 'plan-1', slotId: 's1' } } + }) + ); + expect(result.success).toBe(true); + }); }); diff --git a/frontend/src/routes/(app)/recipes/page.test.ts b/frontend/src/routes/(app)/recipes/page.test.ts index 8fe49fc..27e3ac9 100644 --- a/frontend/src/routes/(app)/recipes/page.test.ts +++ b/frontend/src/routes/(app)/recipes/page.test.ts @@ -8,7 +8,8 @@ const mockData = { { id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' }, { id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' }, { id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' } - ] + ], + activePlan: null }; describe('recipe library page', () => { @@ -68,7 +69,7 @@ describe('recipe library page', () => { }); it('renders empty state page when no recipes at all', () => { - render(Page, { props: { data: { recipes: [] } } }); + render(Page, { props: { data: { recipes: [], activePlan: null } } }); expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument(); });