From dc99459a2e76fbc9e84c0d16b1f6cf7075be2b6f Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 09:41:56 +0200 Subject: [PATCH 1/6] feat(recipes): add RecipeCard component with compact/full image variants Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeCard.svelte | 66 +++++++++++++++++++++ frontend/src/lib/recipes/RecipeCard.test.ts | 62 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 frontend/src/lib/recipes/RecipeCard.svelte create mode 100644 frontend/src/lib/recipes/RecipeCard.test.ts diff --git a/frontend/src/lib/recipes/RecipeCard.svelte b/frontend/src/lib/recipes/RecipeCard.svelte new file mode 100644 index 0000000..97f9d81 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeCard.svelte @@ -0,0 +1,66 @@ + + + +
+ {#if recipe.heroImageUrl} + {recipe.name} + {:else} +
+ +
+ {/if} +
+ +
+

{recipe.name}

+ {#if metadata} +

{metadata}

+ {/if} +
+
diff --git a/frontend/src/lib/recipes/RecipeCard.test.ts b/frontend/src/lib/recipes/RecipeCard.test.ts new file mode 100644 index 0000000..f9792a4 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeCard.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import RecipeCard from './RecipeCard.svelte'; + +const mockRecipe = { + id: 'recipe-1', + name: 'Spaghetti Bolognese', + cookTimeMin: 30, + effort: 'Easy', + heroImageUrl: undefined +}; + +describe('RecipeCard', () => { + it('renders the recipe name', () => { + render(RecipeCard, { props: { recipe: mockRecipe } }); + expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument(); + }); + + it('renders cook time when present', () => { + render(RecipeCard, { props: { recipe: mockRecipe } }); + expect(screen.getByText(/30/)).toBeInTheDocument(); + }); + + it('renders effort when present', () => { + render(RecipeCard, { props: { recipe: mockRecipe } }); + expect(screen.getByText(/easy/i)).toBeInTheDocument(); + }); + + it('shows placeholder when no heroImageUrl', () => { + render(RecipeCard, { props: { recipe: { ...mockRecipe, heroImageUrl: undefined } } }); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(document.querySelector('[data-testid="image-placeholder"]')).toBeInTheDocument(); + }); + + it('shows image when heroImageUrl is provided', () => { + render(RecipeCard, { + props: { recipe: { ...mockRecipe, heroImageUrl: '/uploads/test.jpg' } } + }); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', '/uploads/test.jpg'); + expect(img).toHaveAttribute('alt', 'Spaghetti Bolognese'); + }); + + it('wraps in a link to the recipe detail page', () => { + render(RecipeCard, { props: { recipe: mockRecipe } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/recipes/recipe-1'); + }); + + it('applies compact image height when compact prop is true', () => { + render(RecipeCard, { props: { recipe: mockRecipe, compact: true } }); + const imageArea = document.querySelector('[data-testid="image-area"]'); + expect(imageArea?.className).toContain('h-[64px]'); + }); + + it('applies full image height when compact prop is false', () => { + render(RecipeCard, { props: { recipe: mockRecipe, compact: false } }); + const imageArea = document.querySelector('[data-testid="image-area"]'); + expect(imageArea?.className).toContain('h-[100px]'); + }); +}); From 35ed6ca878caefe7544a9a672e27954b1ccbe7f1 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 09:43:06 +0200 Subject: [PATCH 2/6] feat(recipes): add FilterChipRow with effort filter chips Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/FilterChipRow.svelte | 20 ++++++++ .../src/lib/recipes/FilterChipRow.test.ts | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 frontend/src/lib/recipes/FilterChipRow.svelte create mode 100644 frontend/src/lib/recipes/FilterChipRow.test.ts diff --git a/frontend/src/lib/recipes/FilterChipRow.svelte b/frontend/src/lib/recipes/FilterChipRow.svelte new file mode 100644 index 0000000..571e48b --- /dev/null +++ b/frontend/src/lib/recipes/FilterChipRow.svelte @@ -0,0 +1,20 @@ + + +
+ {#each chips as label (label)} + + {/each} +
diff --git a/frontend/src/lib/recipes/FilterChipRow.test.ts b/frontend/src/lib/recipes/FilterChipRow.test.ts new file mode 100644 index 0000000..ae28811 --- /dev/null +++ b/frontend/src/lib/recipes/FilterChipRow.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import FilterChipRow from './FilterChipRow.svelte'; + +describe('FilterChipRow', () => { + it('renders all effort filter chips', () => { + render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } }); + expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mittel' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Schwer' })).toBeInTheDocument(); + }); + + it('marks active chip with aria-pressed=true', () => { + render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter: vi.fn() } }); + expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Alle' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('marks inactive chips with aria-pressed=false', () => { + render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter: vi.fn() } }); + expect(screen.getByRole('button', { name: 'Leicht' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Mittel' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Schwer' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('calls onFilter with the chip label when clicked', async () => { + const user = userEvent.setup(); + const onFilter = vi.fn(); + render(FilterChipRow, { props: { activeFilter: 'Alle', onFilter } }); + + await user.click(screen.getByRole('button', { name: 'Leicht' })); + expect(onFilter).toHaveBeenCalledWith('Leicht'); + }); + + it('calls onFilter with Alle when reset chip clicked', async () => { + const user = userEvent.setup(); + const onFilter = vi.fn(); + render(FilterChipRow, { props: { activeFilter: 'Leicht', onFilter } }); + + await user.click(screen.getByRole('button', { name: 'Alle' })); + expect(onFilter).toHaveBeenCalledWith('Alle'); + }); + + it('active chip has green-tint styling', () => { + render(FilterChipRow, { props: { activeFilter: 'Mittel', onFilter: vi.fn() } }); + const btn = screen.getByRole('button', { name: 'Mittel' }); + expect(btn.className).toContain('bg-[var(--green-tint)]'); + }); +}); From a733e8dd6674b9e8215532a865fec9f99f1bad25 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 09:44:05 +0200 Subject: [PATCH 3/6] feat(recipes): add RecipeGrid with 2/4-col responsive grid and empty state Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeGrid.svelte | 22 +++++++++++ frontend/src/lib/recipes/RecipeGrid.test.ts | 41 +++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 frontend/src/lib/recipes/RecipeGrid.svelte create mode 100644 frontend/src/lib/recipes/RecipeGrid.test.ts diff --git a/frontend/src/lib/recipes/RecipeGrid.svelte b/frontend/src/lib/recipes/RecipeGrid.svelte new file mode 100644 index 0000000..ac713ff --- /dev/null +++ b/frontend/src/lib/recipes/RecipeGrid.svelte @@ -0,0 +1,22 @@ + + +{#if recipes.length > 0} +
+ {#each recipes as recipe (recipe.id)} + + {/each} +
+{:else} +
+

Noch keine Rezepte vorhanden.

+ + Rezept hinzufügen + +
+{/if} diff --git a/frontend/src/lib/recipes/RecipeGrid.test.ts b/frontend/src/lib/recipes/RecipeGrid.test.ts new file mode 100644 index 0000000..fccdbe0 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeGrid.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import RecipeGrid from './RecipeGrid.svelte'; + +const mockRecipes = [ + { id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' }, + { id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' }, + { id: 'r3', name: 'Caesar Salad', cookTimeMin: 15, effort: 'Easy' } +]; + +describe('RecipeGrid', () => { + it('renders a card for each recipe', () => { + render(RecipeGrid, { props: { recipes: mockRecipes } }); + expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument(); + expect(screen.getByText('Chicken Curry')).toBeInTheDocument(); + expect(screen.getByText('Caesar Salad')).toBeInTheDocument(); + }); + + it('renders 3 links for 3 recipes', () => { + render(RecipeGrid, { props: { recipes: mockRecipes } }); + expect(screen.getAllByRole('link')).toHaveLength(3); + }); + + it('shows empty state when recipes array is empty', () => { + render(RecipeGrid, { props: { recipes: [] } }); + expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument(); + }); + + it('empty state links to recipe creation', () => { + render(RecipeGrid, { props: { recipes: [] } }); + const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i }); + expect(addLink).toHaveAttribute('href', '/recipes/new'); + }); + + it('grid has 2-col mobile and 4-col desktop classes', () => { + render(RecipeGrid, { props: { recipes: mockRecipes } }); + const grid = document.querySelector('[data-testid="recipe-grid"]'); + expect(grid?.className).toContain('grid-cols-2'); + expect(grid?.className).toContain('lg:grid-cols-4'); + }); +}); From a25286e385fec95f304fb3c9eb83dfb48a8df31e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 09:45:43 +0200 Subject: [PATCH 4/6] feat(recipes): load recipe list from API in page server Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/recipes/+page.server.ts | 21 +++++++++ .../routes/(app)/recipes/page.server.test.ts | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 frontend/src/routes/(app)/recipes/+page.server.ts create mode 100644 frontend/src/routes/(app)/recipes/page.server.test.ts diff --git a/frontend/src/routes/(app)/recipes/+page.server.ts b/frontend/src/routes/(app)/recipes/+page.server.ts new file mode 100644 index 0000000..bff4ccb --- /dev/null +++ b/frontend/src/routes/(app)/recipes/+page.server.ts @@ -0,0 +1,21 @@ +import type { PageServerLoad } from './$types'; +import { apiClient } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ fetch }) => { + const api = apiClient(fetch); + const { data, error } = await api.GET('/v1/recipes', {}); + + if (error || !data?.data) { + return { recipes: [] }; + } + + return { + recipes: data.data.map((r) => ({ + id: r.id!, + name: r.name!, + cookTimeMin: r.cookTimeMin, + effort: r.effort, + heroImageUrl: r.heroImageUrl + })) + }; +}; diff --git a/frontend/src/routes/(app)/recipes/page.server.test.ts b/frontend/src/routes/(app)/recipes/page.server.test.ts new file mode 100644 index 0000000..50c1358 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/page.server.test.ts @@ -0,0 +1,45 @@ +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 library page — load', () => { + let load: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + load = mod.load; + }); + + const mockRecipes = [ + { id: 'r1', name: 'Spaghetti', cookTimeMin: 30, effort: 'Easy' }, + { id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' } + ]; + + it('fetches recipes from GET /v1/recipes', async () => { + mockGet.mockResolvedValue({ data: { data: mockRecipes }, 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 }); + 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 } }); + const result = await load({ fetch: vi.fn() } as any); + expect(result.recipes).toEqual([]); + }); +}); From 47c748145d799536c4ddc492172f81941f898534 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 09:49:39 +0200 Subject: [PATCH 5/6] feat(recipes): implement recipe library page with search and effort filtering Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/recipes/+page.svelte | 55 +++++++++++++- .../src/routes/(app)/recipes/page.test.ts | 74 +++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/(app)/recipes/page.test.ts diff --git a/frontend/src/routes/(app)/recipes/+page.svelte b/frontend/src/routes/(app)/recipes/+page.svelte index 1faa0a3..36632de 100644 --- a/frontend/src/routes/(app)/recipes/+page.svelte +++ b/frontend/src/routes/(app)/recipes/+page.svelte @@ -1 +1,54 @@ -

Rezepte

+ + + + Rezepte — Mealplan + + +
+
+

Rezepte

+ Rezept hinzufügen +
+ + + + (activeFilter = f)} /> + + +
diff --git a/frontend/src/routes/(app)/recipes/page.test.ts b/frontend/src/routes/(app)/recipes/page.test.ts new file mode 100644 index 0000000..b55545e --- /dev/null +++ b/frontend/src/routes/(app)/recipes/page.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import Page from './+page.svelte'; + +const mockData = { + recipes: [ + { 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' } + ] +}; + +describe('recipe library page', () => { + it('renders all recipe cards initially', () => { + render(Page, { props: { data: mockData } }); + expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument(); + expect(screen.getByText('Chicken Curry')).toBeInTheDocument(); + expect(screen.getByText('Gemüsesuppe')).toBeInTheDocument(); + }); + + it('renders the page title', () => { + render(Page, { props: { data: mockData } }); + expect(document.title).toBe('Rezepte — Mealplan'); + }); + + it('renders a link to add a new recipe', () => { + render(Page, { props: { data: mockData } }); + const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i }); + expect(addLink).toHaveAttribute('href', '/recipes/new'); + }); + + it('filters recipes by search term', async () => { + const user = userEvent.setup(); + render(Page, { props: { data: mockData } }); + + const searchInput = screen.getByPlaceholderText(/suchen/i); + await user.type(searchInput, 'Curry'); + + expect(screen.getByText('Chicken Curry')).toBeInTheDocument(); + expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument(); + }); + + it('filters recipes by effort chip', async () => { + const user = userEvent.setup(); + render(Page, { props: { data: mockData } }); + + await user.click(screen.getByRole('button', { name: 'Mittel' })); + + expect(screen.getByText('Chicken Curry')).toBeInTheDocument(); + expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument(); + }); + + it('shows empty state when no recipes match search', async () => { + const user = userEvent.setup(); + render(Page, { props: { data: mockData } }); + + const searchInput = screen.getByPlaceholderText(/suchen/i); + await user.type(searchInput, 'xyznotexist'); + + expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument(); + }); + + it('renders filter chips', () => { + render(Page, { props: { data: mockData } }); + expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument(); + }); + + it('renders empty state page when no recipes at all', () => { + render(Page, { props: { data: { recipes: [] } } }); + expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument(); + }); +}); From 9bb6293d9f2a747f7a2302f00a2e5a93403ff197 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 09:53:32 +0200 Subject: [PATCH 6/6] =?UTF-8?q?fix(recipes):=20address=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20shared=20type,=20design=20system=20tokens,=20tes?= =?UTF-8?q?t=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract RecipeSummary type to $lib/recipes/types.ts (was duplicated in 3 files) - Fix +page.svelte header link: replace Skeleton UI classes with design system tokens - Fix h1: use font-[var(--font-display)] and correct size - Fix FilterChipRow: text-[11px] → text-[13px] + tracking-[0.04em] per design system - Fix RecipeCard metadata: text-[11px] → text-[12px] for readability - Remove unused imports (vi, beforeEach, afterEach) from page.test.ts - Add combined search + effort filter test - Add reset-to-Alle filter test Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/FilterChipRow.svelte | 2 +- frontend/src/lib/recipes/RecipeCard.svelte | 10 ++----- frontend/src/lib/recipes/RecipeGrid.svelte | 3 +-- frontend/src/lib/recipes/types.ts | 7 +++++ .../src/routes/(app)/recipes/+page.svelte | 13 +++------ .../src/routes/(app)/recipes/page.test.ts | 27 ++++++++++++++++++- 6 files changed, 40 insertions(+), 22 deletions(-) create mode 100644 frontend/src/lib/recipes/types.ts diff --git a/frontend/src/lib/recipes/FilterChipRow.svelte b/frontend/src/lib/recipes/FilterChipRow.svelte index 571e48b..bac6cae 100644 --- a/frontend/src/lib/recipes/FilterChipRow.svelte +++ b/frontend/src/lib/recipes/FilterChipRow.svelte @@ -10,7 +10,7 @@ type="button" aria-pressed={activeFilter === label} onclick={() => onFilter(label)} - class="font-sans text-[11px] font-medium px-[14px] py-[5px] rounded-[12px] border cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)] {activeFilter === label + class="font-sans text-[13px] font-medium tracking-[0.04em] px-[14px] py-[5px] rounded-[12px] border cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)] {activeFilter === label ? 'bg-[var(--green-tint)] text-[var(--green-dark)] border-[var(--green-light)]' : 'bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--color-border)]'}" > diff --git a/frontend/src/lib/recipes/RecipeCard.svelte b/frontend/src/lib/recipes/RecipeCard.svelte index 97f9d81..8140373 100644 --- a/frontend/src/lib/recipes/RecipeCard.svelte +++ b/frontend/src/lib/recipes/RecipeCard.svelte @@ -1,11 +1,5 @@ diff --git a/frontend/src/lib/recipes/types.ts b/frontend/src/lib/recipes/types.ts new file mode 100644 index 0000000..9d8284b --- /dev/null +++ b/frontend/src/lib/recipes/types.ts @@ -0,0 +1,7 @@ +export type RecipeSummary = { + id: string; + name: string; + cookTimeMin?: number; + effort?: string; + heroImageUrl?: string; +}; diff --git a/frontend/src/routes/(app)/recipes/+page.svelte b/frontend/src/routes/(app)/recipes/+page.svelte index 36632de..3b7a089 100644 --- a/frontend/src/routes/(app)/recipes/+page.svelte +++ b/frontend/src/routes/(app)/recipes/+page.svelte @@ -1,14 +1,7 @@