diff --git a/frontend/src/lib/recipes/IngredientList.svelte b/frontend/src/lib/recipes/IngredientList.svelte
new file mode 100644
index 0000000..0631db4
--- /dev/null
+++ b/frontend/src/lib/recipes/IngredientList.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+ Zutaten
+
+
+
+ {#each sortedIngredients as ingredient (ingredient.ingredientId ?? ingredient.name)}
+ -
+ {#if ingredient.quantity != null}
+
+ {ingredient.quantity}{ingredient.unit != null ? ` ${ingredient.unit}` : ''}
+
+ {/if}
+ {ingredient.name}
+
+ {/each}
+
+
diff --git a/frontend/src/lib/recipes/IngredientList.test.ts b/frontend/src/lib/recipes/IngredientList.test.ts
new file mode 100644
index 0000000..ffc2c95
--- /dev/null
+++ b/frontend/src/lib/recipes/IngredientList.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import IngredientList from './IngredientList.svelte';
+
+const mockIngredients = [
+ { ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
+ { ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' },
+ { ingredientId: 'i3', name: 'Salz', quantity: undefined, unit: undefined }
+];
+
+describe('IngredientList', () => {
+ it('renders the section heading', () => {
+ render(IngredientList, { props: { ingredients: mockIngredients } });
+ expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
+ });
+
+ it('renders a row for each ingredient', () => {
+ render(IngredientList, { props: { ingredients: mockIngredients } });
+ expect(screen.getByText('Spaghetti')).toBeInTheDocument();
+ expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
+ expect(screen.getByText('Salz')).toBeInTheDocument();
+ });
+
+ it('renders quantity and unit when present', () => {
+ render(IngredientList, { props: { ingredients: mockIngredients } });
+ expect(screen.getByText('200 g')).toBeInTheDocument();
+ expect(screen.getByText('400 g')).toBeInTheDocument();
+ });
+
+ it('renders no quantity when not present', () => {
+ render(IngredientList, { props: { ingredients: mockIngredients } });
+ expect(screen.queryByText('undefined')).not.toBeInTheDocument();
+ });
+
+ it('has no remove buttons (read-only)', () => {
+ render(IngredientList, { props: { ingredients: mockIngredients } });
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+
+ it('renders empty state when ingredients array is empty', () => {
+ render(IngredientList, { props: { ingredients: [] } });
+ expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
+ });
+
+ it('renders ingredients sorted by sortOrder', () => {
+ const unsorted = [
+ { ingredientId: 'i3', name: 'Oregano', quantity: 1, unit: 'TL', sortOrder: 3 },
+ { ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g', sortOrder: 1 },
+ { ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g', sortOrder: 2 }
+ ];
+ render(IngredientList, { props: { ingredients: unsorted } });
+ const spans = document.querySelectorAll('li span:last-child');
+ expect(spans[0].textContent).toBe('Spaghetti');
+ expect(spans[1].textContent).toBe('Hackfleisch');
+ expect(spans[2].textContent).toBe('Oregano');
+ });
+});
diff --git a/frontend/src/lib/recipes/RecipeHero.svelte b/frontend/src/lib/recipes/RecipeHero.svelte
new file mode 100644
index 0000000..19c7144
--- /dev/null
+++ b/frontend/src/lib/recipes/RecipeHero.svelte
@@ -0,0 +1,70 @@
+
+
+
+ {#if hasImage}
+

+
+ {/if}
+
+
+
← Zurück
+
+
+ {recipe.name}
+
+
+
+ {#if recipe.cookTimeMin != null}
+ {recipe.cookTimeMin} Min
+ {/if}
+ {#if recipe.effort}
+ {recipe.effort}
+ {/if}
+ {#if recipe.serves != null}
+ {recipe.serves} Port.
+ {/if}
+ {#each recipe.tags as tag (tag.id)}
+ {tag.name}
+ {/each}
+
+
+
Jetzt kochen
+
+
diff --git a/frontend/src/lib/recipes/RecipeHero.test.ts b/frontend/src/lib/recipes/RecipeHero.test.ts
new file mode 100644
index 0000000..acf53c7
--- /dev/null
+++ b/frontend/src/lib/recipes/RecipeHero.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import RecipeHero from './RecipeHero.svelte';
+
+const baseRecipe = {
+ id: 'r1',
+ name: 'Spaghetti Bolognese',
+ serves: 4,
+ cookTimeMin: 30,
+ effort: 'Easy',
+ heroImageUrl: undefined as string | undefined,
+ tags: [] as { id: string; name: string; tagType?: string }[]
+};
+
+describe('RecipeHero', () => {
+ it('renders the recipe name', () => {
+ render(RecipeHero, { props: { recipe: baseRecipe } });
+ expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
+ });
+
+ it('renders green-tint hero when no image', () => {
+ render(RecipeHero, { props: { recipe: baseRecipe } });
+ const hero = document.querySelector('[data-testid="recipe-hero"]');
+ expect(hero?.className).toContain('bg-[var(--green-tint)]');
+ });
+
+ it('renders image when heroImageUrl is provided', () => {
+ render(RecipeHero, {
+ props: { recipe: { ...baseRecipe, heroImageUrl: '/uploads/pasta.jpg' } }
+ });
+ const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
+ expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
+ });
+
+ it('renders cook time pill', () => {
+ render(RecipeHero, { props: { recipe: baseRecipe } });
+ expect(screen.getByText(/30 Min/)).toBeInTheDocument();
+ });
+
+ it('renders effort pill', () => {
+ render(RecipeHero, { props: { recipe: baseRecipe } });
+ expect(screen.getByText(/Easy/)).toBeInTheDocument();
+ });
+
+ it('renders serves pill', () => {
+ render(RecipeHero, { props: { recipe: baseRecipe } });
+ expect(screen.getByText(/4/)).toBeInTheDocument();
+ });
+
+ it('renders back link to /recipes', () => {
+ render(RecipeHero, { props: { recipe: baseRecipe } });
+ const backLink = screen.getByRole('link', { name: /zurück/i });
+ expect(backLink).toHaveAttribute('href', '/recipes');
+ });
+
+ it('renders cook now link to /cook/[id]', () => {
+ render(RecipeHero, { props: { recipe: baseRecipe } });
+ const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
+ expect(cookLink).toHaveAttribute('href', '/cook/r1');
+ });
+
+ it('does not render img when no heroImageUrl', () => {
+ render(RecipeHero, { props: { recipe: baseRecipe } });
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+
+ it('renders tag pills', () => {
+ render(RecipeHero, {
+ props: {
+ recipe: {
+ ...baseRecipe,
+ tags: [
+ { id: 't1', name: 'Pasta' },
+ { id: 't2', name: 'Italienisch' }
+ ]
+ }
+ }
+ });
+ expect(screen.getByText('Pasta')).toBeInTheDocument();
+ expect(screen.getByText('Italienisch')).toBeInTheDocument();
+ });
+
+ it('renders no tag pills when tags array is empty', () => {
+ render(RecipeHero, { props: { recipe: { ...baseRecipe, tags: [] } } });
+ expect(screen.queryByText('Pasta')).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/recipes/StepList.svelte b/frontend/src/lib/recipes/StepList.svelte
new file mode 100644
index 0000000..d888392
--- /dev/null
+++ b/frontend/src/lib/recipes/StepList.svelte
@@ -0,0 +1,33 @@
+
+
+
+
+ Zubereitung
+
+
+ {#each sortedSteps as step (step.stepNumber)}
+ -
+
+ {step.stepNumber}
+
+
+ {step.instruction}
+
+
+ {/each}
+
+
diff --git a/frontend/src/lib/recipes/StepList.test.ts b/frontend/src/lib/recipes/StepList.test.ts
new file mode 100644
index 0000000..5c8697b
--- /dev/null
+++ b/frontend/src/lib/recipes/StepList.test.ts
@@ -0,0 +1,50 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import StepList from './StepList.svelte';
+
+const mockSteps = [
+ { stepNumber: 1, instruction: 'Wasser zum Kochen bringen' },
+ { stepNumber: 2, instruction: 'Spaghetti al dente kochen' },
+ { stepNumber: 3, instruction: 'Sauce bereiten' }
+];
+
+describe('StepList', () => {
+ it('renders the section heading', () => {
+ render(StepList, { props: { steps: mockSteps } });
+ expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
+ });
+
+ it('renders each step instruction', () => {
+ render(StepList, { props: { steps: mockSteps } });
+ expect(screen.getByText('Wasser zum Kochen bringen')).toBeInTheDocument();
+ expect(screen.getByText('Spaghetti al dente kochen')).toBeInTheDocument();
+ expect(screen.getByText('Sauce bereiten')).toBeInTheDocument();
+ });
+
+ it('renders step numbers', () => {
+ render(StepList, { props: { steps: mockSteps } });
+ expect(screen.getByText('1')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+
+ it('renders numbered circles with step numbers', () => {
+ render(StepList, { props: { steps: mockSteps } });
+ const circles = document.querySelectorAll('[data-testid="step-circle"]');
+ expect(circles).toHaveLength(3);
+ });
+
+ it('renders steps in stepNumber order', () => {
+ const shuffled = [mockSteps[2], mockSteps[0], mockSteps[1]];
+ render(StepList, { props: { steps: shuffled } });
+ const circles = document.querySelectorAll('[data-testid="step-circle"]');
+ expect(circles[0].textContent).toBe('1');
+ expect(circles[1].textContent).toBe('2');
+ expect(circles[2].textContent).toBe('3');
+ });
+
+ it('renders empty state when no steps', () => {
+ render(StepList, { props: { steps: [] } });
+ expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/recipes/types.ts b/frontend/src/lib/recipes/types.ts
index 9d8284b..90f28b5 100644
--- a/frontend/src/lib/recipes/types.ts
+++ b/frontend/src/lib/recipes/types.ts
@@ -5,3 +5,34 @@ export type RecipeSummary = {
effort?: string;
heroImageUrl?: string;
};
+
+export type Tag = {
+ id: string;
+ name: string;
+ tagType?: string;
+};
+
+export type Ingredient = {
+ ingredientId?: string;
+ name?: string;
+ quantity?: number;
+ unit?: string;
+ sortOrder?: number;
+};
+
+export type Step = {
+ stepNumber?: number;
+ instruction?: string;
+};
+
+export type RecipeDetail = {
+ id: string;
+ name: string;
+ serves?: number;
+ cookTimeMin?: number;
+ effort?: string;
+ heroImageUrl?: string;
+ ingredients: Ingredient[];
+ steps: Step[];
+ tags: Tag[];
+};
diff --git a/frontend/src/routes/(app)/recipes/[id]/+page.server.ts b/frontend/src/routes/(app)/recipes/[id]/+page.server.ts
new file mode 100644
index 0000000..400a297
--- /dev/null
+++ b/frontend/src/routes/(app)/recipes/[id]/+page.server.ts
@@ -0,0 +1,41 @@
+import { error } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+import { apiClient } from '$lib/server/api';
+
+export const load: PageServerLoad = async ({ fetch, params }) => {
+ const api = apiClient(fetch);
+ const { data, error: apiError } = await api.GET('/v1/recipes/{id}', {
+ params: { path: { id: params.id } }
+ });
+
+ if (apiError || !data) {
+ error(404, 'Recipe not found');
+ }
+
+ return {
+ recipe: {
+ id: data.id!,
+ name: data.name!,
+ serves: data.serves,
+ cookTimeMin: data.cookTimeMin,
+ effort: data.effort,
+ heroImageUrl: data.heroImageUrl,
+ ingredients: (data.ingredients ?? []).map((ing) => ({
+ ingredientId: ing.ingredientId,
+ name: ing.name,
+ quantity: ing.quantity,
+ unit: ing.unit,
+ sortOrder: ing.sortOrder
+ })),
+ steps: (data.steps ?? []).map((s) => ({
+ stepNumber: s.stepNumber,
+ instruction: s.instruction
+ })),
+ tags: (data.tags ?? []).map((t) => ({
+ id: t.id!,
+ name: t.name!,
+ tagType: t.tagType
+ }))
+ }
+ };
+};
diff --git a/frontend/src/routes/(app)/recipes/[id]/+page.svelte b/frontend/src/routes/(app)/recipes/[id]/+page.svelte
new file mode 100644
index 0000000..d90bde0
--- /dev/null
+++ b/frontend/src/routes/(app)/recipes/[id]/+page.svelte
@@ -0,0 +1,34 @@
+
+
+
+ {data.recipe.name} — Mealplan
+
+
+
diff --git a/frontend/src/routes/(app)/recipes/[id]/page.server.test.ts b/frontend/src/routes/(app)/recipes/[id]/page.server.test.ts
new file mode 100644
index 0000000..8eeebd4
--- /dev/null
+++ b/frontend/src/routes/(app)/recipes/[id]/page.server.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('$env/dynamic/private', () => ({
+ env: { BACKEND_URL: 'http://localhost:8080' }
+}));
+
+const mockGet = vi.fn();
+vi.mock('$lib/server/api', () => ({
+ apiClient: () => ({ GET: mockGet })
+}));
+
+describe('recipe detail page — load', () => {
+ let load: any;
+
+ beforeEach(async () => {
+ mockGet.mockReset();
+ vi.resetModules();
+ const mod = await import('./+page.server');
+ load = mod.load;
+ });
+
+ const mockRecipe = {
+ id: 'r1',
+ name: 'Spaghetti Bolognese',
+ serves: 4,
+ cookTimeMin: 30,
+ effort: 'Easy',
+ heroImageUrl: undefined,
+ ingredients: [
+ { ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }
+ ],
+ steps: [{ stepNumber: 1, instruction: 'Kochen' }],
+ tags: []
+ };
+
+ it('fetches recipe from GET /v1/recipes/{id}', async () => {
+ mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
+ await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
+ expect(mockGet).toHaveBeenCalledWith('/v1/recipes/{id}', expect.objectContaining({
+ params: { path: { id: 'r1' } }
+ }));
+ });
+
+ it('returns recipe data on success', async () => {
+ mockGet.mockResolvedValue({ data: mockRecipe, error: undefined });
+ const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
+ expect(result.recipe.name).toBe('Spaghetti Bolognese');
+ expect(result.recipe.serves).toBe(4);
+ });
+
+ it('throws 404 error when API returns error', async () => {
+ mockGet.mockResolvedValue({ data: undefined, error: { status: 404 } });
+ await expect(
+ load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
+ ).rejects.toMatchObject({ status: 404 });
+ });
+
+ it('throws 404 error when API returns 403 (different household — intentional)', async () => {
+ // Security design: we return 404 for both "not found" and "forbidden"
+ // to avoid revealing resource existence to unauthorized users
+ mockGet.mockResolvedValue({ data: undefined, error: { status: 403 } });
+ await expect(
+ load({ fetch: vi.fn(), params: { id: 'r-other-household' } } as any)
+ ).rejects.toMatchObject({ status: 404 });
+ });
+});
diff --git a/frontend/src/routes/(app)/recipes/[id]/page.test.ts b/frontend/src/routes/(app)/recipes/[id]/page.test.ts
new file mode 100644
index 0000000..c25139c
--- /dev/null
+++ b/frontend/src/routes/(app)/recipes/[id]/page.test.ts
@@ -0,0 +1,92 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import Page from './+page.svelte';
+
+const mockData = {
+ recipe: {
+ id: 'r1',
+ name: 'Spaghetti Bolognese',
+ serves: 4,
+ cookTimeMin: 30,
+ effort: 'Easy',
+ heroImageUrl: undefined as string | undefined,
+ ingredients: [
+ { ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' },
+ { ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g' }
+ ],
+ steps: [
+ { stepNumber: 1, instruction: 'Wasser aufsetzen' },
+ { stepNumber: 2, instruction: 'Sauce zubereiten' }
+ ],
+ tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
+ }
+};
+
+describe('recipe detail page', () => {
+ it('renders the recipe name', () => {
+ render(Page, { props: { data: mockData } });
+ expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
+ });
+
+ it('renders page title', () => {
+ render(Page, { props: { data: mockData } });
+ expect(document.title).toBe('Spaghetti Bolognese — Mealplan');
+ });
+
+ it('renders back link to /recipes', () => {
+ render(Page, { props: { data: mockData } });
+ const backLink = screen.getByRole('link', { name: /zurück/i });
+ expect(backLink).toHaveAttribute('href', '/recipes');
+ });
+
+ it('renders cook now link to /cook/[id]', () => {
+ render(Page, { props: { data: mockData } });
+ const cookLink = screen.getByRole('link', { name: /jetzt kochen/i });
+ expect(cookLink).toHaveAttribute('href', '/cook/r1');
+ });
+
+ it('renders ingredients section heading', () => {
+ render(Page, { props: { data: mockData } });
+ expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
+ });
+
+ it('renders steps section heading', () => {
+ render(Page, { props: { data: mockData } });
+ expect(screen.getByText(/zubereitung/i)).toBeInTheDocument();
+ });
+
+ it('renders ingredient names', () => {
+ render(Page, { props: { data: mockData } });
+ expect(screen.getByText('Spaghetti')).toBeInTheDocument();
+ expect(screen.getByText('Hackfleisch')).toBeInTheDocument();
+ });
+
+ it('renders step instructions', () => {
+ render(Page, { props: { data: mockData } });
+ expect(screen.getByText('Wasser aufsetzen')).toBeInTheDocument();
+ expect(screen.getByText('Sauce zubereiten')).toBeInTheDocument();
+ });
+
+ it('renders edit link to /recipes/[id]/edit', () => {
+ render(Page, { props: { data: mockData } });
+ const editLink = screen.getByRole('link', { name: /bearbeiten/i });
+ expect(editLink).toHaveAttribute('href', '/recipes/r1/edit');
+ });
+
+ it('renders tag pills in hero', () => {
+ render(Page, { props: { data: mockData } });
+ expect(screen.getByText('Pasta')).toBeInTheDocument();
+ });
+
+ it('renders hero image when heroImageUrl is provided', () => {
+ render(Page, {
+ props: {
+ data: {
+ recipe: { ...mockData.recipe, heroImageUrl: '/uploads/pasta.jpg' }
+ }
+ }
+ });
+ const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
+ expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
+ });
+});