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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (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);
+ });
+});