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