diff --git a/frontend/src/routes/(app)/planner/+page.server.ts b/frontend/src/routes/(app)/planner/+page.server.ts index fee0c24..b011745 100644 --- a/frontend/src/routes/(app)/planner/+page.server.ts +++ b/frontend/src/routes/(app)/planner/+page.server.ts @@ -27,6 +27,52 @@ export const load: PageServerLoad = async ({ fetch, url }) => { }; 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 }; + }, + createPlan: async ({ fetch, request, locals }) => { // Role guard: only planners may create week plans if (locals.benutzer?.rolle !== 'planer') { diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte index 373e481..68f8d1b 100644 --- a/frontend/src/routes/(app)/planner/+page.svelte +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -1,14 +1,17 @@ @@ -76,12 +162,13 @@ › {#if isPlanner} - (pickerOpen = true)} class="rounded-[var(--radius-md)] bg-[var(--green-dark)] px-3 py-1.5 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white" > + Gericht - + {/if} @@ -128,7 +215,7 @@ Restliche Woche
- {#each remainingSlotsWithMeal as slot} + {#each remainingSlotsWithMeal as slot (slot.slotDate)}
{/if} + + + (pickerOpen = false)}> + s.recipe).filter(Boolean) ?? []} + onpick={handleRecipePick} + /> + @@ -200,12 +300,13 @@ {#if isPlanner} - (panelState = { kind: 'recipe-picker', date: selectedDay })} class="ml-auto rounded-[var(--radius-md)] bg-[var(--green-dark)] px-4 py-2 text-[13px] font-medium tracking-[0.04em] font-[var(--font-sans)] text-white" > + Gericht hinzufügen - + {/if} @@ -240,7 +341,7 @@ {:else}
- {#each days as day} + {#each days as day (day)} {@const slot = slotMap[day] ?? { id: null, slotDate: day, recipe: null }} {@const isTodayDay = day === today} {@const isSelectedDay = day === selectedDay} @@ -266,7 +367,12 @@ +
- -
- - Rezept ansehen - - - Koch-Modus - - - {#if isPlanner} + {#if detailSlot.recipe} +

+ {detailSlot.recipe.name} +

+ {#if detailSlot.recipe.effort || detailSlot.recipe.cookTimeMin} +

+ {[detailSlot.recipe.cookTimeMin ? `${detailSlot.recipe.cookTimeMin} Min` : null, detailSlot.recipe.effort].filter(Boolean).join(' · ')} +

+ {/if} + +
- Gericht tauschen + Rezept ansehen + + Koch-Modus + + {#if isPlanner} + + {/if} +
+ {:else} +

Kein Gericht geplant

+ {#if isPlanner} + {/if} -
- {:else} -

Kein Gericht geplant

- {#if isPlanner} - - + Gericht wählen - {/if} + + {:else if panelState.kind === 'recipe-picker'} + {@const pickerDate = panelState.date} + + +
+

+ Rezept wählen +

+ +
+ +
+ s.recipe).filter(Boolean) ?? []} + onpick={handleRecipePick} + /> +
{/if} + + + + + + (undoVisible = false)} +/> diff --git a/frontend/src/routes/(app)/planner/page.server.test.ts b/frontend/src/routes/(app)/planner/page.server.test.ts index 8cad15a..4c47299 100644 --- a/frontend/src/routes/(app)/planner/page.server.test.ts +++ b/frontend/src/routes/(app)/planner/page.server.test.ts @@ -6,8 +6,10 @@ 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, POST: mockPost }) + apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete }) })); describe('planner page — load', () => { @@ -193,3 +195,89 @@ describe('planner page — variety score partial failure', () => { expect(result.varietyScore).toBeNull(); }); }); + +describe('planner page — slot 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 with slot', 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', slotDate: '2026-04-01' }, 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); + expect(result.slot?.id).toBe('s1'); + }); + + it('addSlot returns failure when API errors', 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', 'r2'); + 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: 'r2' } + }) + ); + 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); + }); +});