From 33cccd3d634aa59e2c678e65a78496663be14870 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:23:15 +0200 Subject: [PATCH 01/16] feat(settings): add +page.server.ts loading stapleCount, memberCount, userName Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/settings/+page.server.ts | 20 ++++ .../routes/(app)/settings/page.server.test.ts | 105 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 frontend/src/routes/(app)/settings/+page.server.ts create mode 100644 frontend/src/routes/(app)/settings/page.server.test.ts diff --git a/frontend/src/routes/(app)/settings/+page.server.ts b/frontend/src/routes/(app)/settings/+page.server.ts new file mode 100644 index 0000000..127f8c9 --- /dev/null +++ b/frontend/src/routes/(app)/settings/+page.server.ts @@ -0,0 +1,20 @@ +import type { PageServerLoad } from './$types'; +import { apiClient } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ fetch, locals }) => { + const api = apiClient(fetch); + + const [ingredientsRes, householdRes] = await Promise.all([ + api.GET('/v1/ingredients'), + api.GET('/v1/households/mine') + ]); + + const stapleCount = ingredientsRes.data?.filter((i) => i.isStaple).length ?? 0; + const memberCount = householdRes.data?.data?.members?.length ?? 0; + + return { + stapleCount, + memberCount, + userName: locals.benutzer!.name + }; +}; diff --git a/frontend/src/routes/(app)/settings/page.server.test.ts b/frontend/src/routes/(app)/settings/page.server.test.ts new file mode 100644 index 0000000..788cd81 --- /dev/null +++ b/frontend/src/routes/(app)/settings/page.server.test.ts @@ -0,0 +1,105 @@ +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 }) +})); + +const mockIngredients = [ + { id: 'ing-1', name: 'Olivenöl', isStaple: true }, + { id: 'ing-2', name: 'Butter', isStaple: false }, + { id: 'ing-3', name: 'Salz', isStaple: true } +]; + +const mockHousehold = { + status: 'OK', + data: { + id: 'hh-1', + name: 'Familie Raddatz', + members: [ + { userId: 'u-1', name: 'Marcel' }, + { userId: 'u-2', name: 'Anna' }, + { userId: 'u-3', name: 'Ben' } + ] + } +}; + +const mockLocals = { benutzer: { id: 'u-1', name: 'Marcel Raddatz' } }; + +describe('settings page — load', () => { + let load: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + load = mod.load; + }); + + function mockApiResponses() { + mockGet.mockImplementation((path: string) => { + if (path === '/v1/ingredients') { + return Promise.resolve({ data: mockIngredients, error: undefined }); + } + if (path === '/v1/households/mine') { + return Promise.resolve({ data: mockHousehold, error: undefined }); + } + }); + } + + it('returns stapleCount as number of ingredients where isStaple=true', async () => { + mockApiResponses(); + const result = await load({ fetch: vi.fn(), locals: mockLocals } as any); + expect(result.stapleCount).toBe(2); + }); + + it('returns memberCount as number of household members', async () => { + mockApiResponses(); + const result = await load({ fetch: vi.fn(), locals: mockLocals } as any); + expect(result.memberCount).toBe(3); + }); + + it('returns userName from locals.benutzer.name', async () => { + mockApiResponses(); + const result = await load({ fetch: vi.fn(), locals: mockLocals } as any); + expect(result.userName).toBe('Marcel Raddatz'); + }); + + it('fetches ingredients and household in parallel', async () => { + mockApiResponses(); + await load({ fetch: vi.fn(), locals: mockLocals } as any); + const calls = mockGet.mock.calls.map((c) => c[0]); + expect(calls).toContain('/v1/ingredients'); + expect(calls).toContain('/v1/households/mine'); + }); + + it('defaults stapleCount to 0 when ingredients API fails', async () => { + mockGet.mockImplementation((path: string) => { + if (path === '/v1/ingredients') { + return Promise.resolve({ data: undefined, error: { status: 500 } }); + } + if (path === '/v1/households/mine') { + return Promise.resolve({ data: mockHousehold, error: undefined }); + } + }); + const result = await load({ fetch: vi.fn(), locals: mockLocals } as any); + expect(result.stapleCount).toBe(0); + }); + + it('defaults memberCount to 0 when household API fails', async () => { + mockGet.mockImplementation((path: string) => { + if (path === '/v1/ingredients') { + return Promise.resolve({ data: mockIngredients, error: undefined }); + } + if (path === '/v1/households/mine') { + return Promise.resolve({ data: undefined, error: { status: 500 } }); + } + }); + const result = await load({ fetch: vi.fn(), locals: mockLocals } as any); + expect(result.memberCount).toBe(0); + }); +}); -- 2.49.1 From 3f9fb900c4c9199a72086d28ad409811ce186a3f Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:24:38 +0200 Subject: [PATCH 02/16] feat(settings): add SettingsCard component with title, href, cta, meta, accent props Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/SettingsCard.svelte | 32 ++++++++++++++ .../src/lib/components/SettingsCard.test.ts | 43 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 frontend/src/lib/components/SettingsCard.svelte create mode 100644 frontend/src/lib/components/SettingsCard.test.ts diff --git a/frontend/src/lib/components/SettingsCard.svelte b/frontend/src/lib/components/SettingsCard.svelte new file mode 100644 index 0000000..404cf8c --- /dev/null +++ b/frontend/src/lib/components/SettingsCard.svelte @@ -0,0 +1,32 @@ + + + + + {title} + + + {#if meta} +

+ {meta} +

+ {/if} + + + {cta} + +
diff --git a/frontend/src/lib/components/SettingsCard.test.ts b/frontend/src/lib/components/SettingsCard.test.ts new file mode 100644 index 0000000..73d53e2 --- /dev/null +++ b/frontend/src/lib/components/SettingsCard.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import SettingsCard from './SettingsCard.svelte'; + +describe('SettingsCard', () => { + it('renders the title', () => { + render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } }); + expect(screen.getByText('Vorräte')).toBeInTheDocument(); + }); + + it('renders as an anchor tag with the given href', () => { + render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples?ctx=settings', cta: 'Bearbeiten →' } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/household/staples?ctx=settings'); + }); + + it('renders the cta text', () => { + render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Vorräte bearbeiten →' } }); + expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument(); + }); + + it('renders meta text when provided', () => { + render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →', meta: '3 Mitglieder' } }); + expect(screen.getByText('3 Mitglieder')).toBeInTheDocument(); + }); + + it('does not render meta element when meta is not provided', () => { + render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Mitglieder anzeigen →' } }); + expect(screen.queryByTestId('card-meta')).not.toBeInTheDocument(); + }); + + it('applies accent border style when accent=true', () => { + render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Bearbeiten →', accent: true } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('data-accent', 'true'); + }); + + it('does not apply accent when accent is not set', () => { + render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Anzeigen →' } }); + const link = screen.getByRole('link'); + expect(link).not.toHaveAttribute('data-accent', 'true'); + }); +}); -- 2.49.1 From 109b41b4348dc5e9da68404908019e430ac0dad6 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:27:18 +0200 Subject: [PATCH 03/16] =?UTF-8?q?feat(settings):=20implement=20settings=20?= =?UTF-8?q?hub=20page=20with=20three=20cards=20(Vorr=C3=A4te,=20Haushalt,?= =?UTF-8?q?=20Profil)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/settings/+page.svelte | 71 ++++++++++++++++++- .../src/routes/(app)/settings/page.test.ts | 71 +++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/(app)/settings/page.test.ts diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index f369397..10787d0 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -1 +1,70 @@ -

Einstellungen

+ + +

Einstellungen

+ + diff --git a/frontend/src/routes/(app)/settings/page.test.ts b/frontend/src/routes/(app)/settings/page.test.ts new file mode 100644 index 0000000..c54d313 --- /dev/null +++ b/frontend/src/routes/(app)/settings/page.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import Page from './+page.svelte'; + +function makeData(overrides: Partial<{ stapleCount: number; memberCount: number; userName: string }> = {}) { + return { + stapleCount: 14, + memberCount: 3, + userName: 'Marcel Raddatz', + ...overrides + }; +} + +describe('settings page — hub', () => { + it('renders the page heading Einstellungen', () => { + render(Page, { props: { data: makeData() } }); + expect(screen.getByRole('heading', { name: /einstellungen/i })).toBeInTheDocument(); + }); + + it('renders Vorräte card linking to /household/staples?ctx=settings', () => { + render(Page, { props: { data: makeData() } }); + const links = screen.getAllByRole('link'); + const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples?ctx=settings'); + expect(vorrateLink).toBeInTheDocument(); + }); + + it('renders Haushalt card linking to /members', () => { + render(Page, { props: { data: makeData() } }); + const links = screen.getAllByRole('link'); + const haushaltLink = links.find((l) => l.getAttribute('href') === '/members'); + expect(haushaltLink).toBeInTheDocument(); + }); + + it('renders Profil card linking to /profile', () => { + render(Page, { props: { data: makeData() } }); + const links = screen.getAllByRole('link'); + const profilLink = links.find((l) => l.getAttribute('href') === '/profile'); + expect(profilLink).toBeInTheDocument(); + }); + + it('shows stapleCount as a number in the Vorräte card', () => { + render(Page, { props: { data: makeData({ stapleCount: 14 }) } }); + expect(screen.getByTestId('staple-count')).toHaveTextContent('14'); + }); + + it('shows memberCount in the Haushalt card', () => { + render(Page, { props: { data: makeData({ memberCount: 3 }) } }); + expect(screen.getByTestId('member-count')).toHaveTextContent('3'); + }); + + it('shows userName in the Profil card meta', () => { + render(Page, { props: { data: makeData({ userName: 'Marcel Raddatz' }) } }); + expect(screen.getByText('Marcel Raddatz')).toBeInTheDocument(); + }); + + it('shows empty state text when stapleCount is 0', () => { + render(Page, { props: { data: makeData({ stapleCount: 0 }) } }); + expect(screen.getByText(/noch keine vorräte/i)).toBeInTheDocument(); + expect(screen.queryByTestId('staple-count')).not.toBeInTheDocument(); + }); + + it('shows "Jetzt einrichten →" CTA when stapleCount is 0', () => { + render(Page, { props: { data: makeData({ stapleCount: 0 }) } }); + expect(screen.getByText('Jetzt einrichten →')).toBeInTheDocument(); + }); + + it('shows "Vorräte bearbeiten →" CTA when stapleCount > 0', () => { + render(Page, { props: { data: makeData({ stapleCount: 5 }) } }); + expect(screen.getByText('Vorräte bearbeiten →')).toBeInTheDocument(); + }); +}); -- 2.49.1 From 0b3d062ed12ea62e2cd8775f80b1b117ca214d80 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:28:39 +0200 Subject: [PATCH 04/16] =?UTF-8?q?feat(settings):=20add=20=E2=86=90=20Einst?= =?UTF-8?q?ellungen=20back-link=20on=20D3=20staples=20page=20when=20ctx=3D?= =?UTF-8?q?settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/household/staples/+page.svelte | 3 +++ .../src/routes/household/staples/page.test.ts | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/frontend/src/routes/household/staples/+page.svelte b/frontend/src/routes/household/staples/+page.svelte index 5db2188..7714ffa 100644 --- a/frontend/src/routes/household/staples/+page.svelte +++ b/frontend/src/routes/household/staples/+page.svelte @@ -45,6 +45,9 @@ {:else}
+ {#if data.ctx === 'settings'} + ← Einstellungen + {/if}

Vorräte

diff --git a/frontend/src/routes/household/staples/page.test.ts b/frontend/src/routes/household/staples/page.test.ts index 873bbc9..ecdb05c 100644 --- a/frontend/src/routes/household/staples/page.test.ts +++ b/frontend/src/routes/household/staples/page.test.ts @@ -79,4 +79,31 @@ describe('staples page — settings context (no ctx)', () => { render(Page, { props: { data: { categories: mockCategories, ctx: null } } }); expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument(); }); + + it('does not render back-link when ctx is null', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: null } } }); + expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument(); + }); +}); + +describe('staples page — ctx=settings (D3)', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders back-link "← Einstellungen" when ctx=settings', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: 'settings' } } }); + const backLink = screen.getByRole('link', { name: /← einstellungen/i }); + expect(backLink).toBeInTheDocument(); + }); + + it('back-link points to /settings', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: 'settings' } } }); + const backLink = screen.getByRole('link', { name: /← einstellungen/i }); + expect(backLink).toHaveAttribute('href', '/settings'); + }); }); -- 2.49.1 From 48802a04f7779b4846b75bb652431b8b3882d37d Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:30:19 +0200 Subject: [PATCH 05/16] feat(settings): add autosave hint text below StaplesManager on D3 when ctx=settings Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/household/staples/+page.svelte | 3 +++ .../src/routes/household/staples/page.test.ts | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/frontend/src/routes/household/staples/+page.svelte b/frontend/src/routes/household/staples/+page.svelte index 7714ffa..a8012a2 100644 --- a/frontend/src/routes/household/staples/+page.svelte +++ b/frontend/src/routes/household/staples/+page.svelte @@ -50,5 +50,8 @@ {/if}

Vorräte

+ {#if data.ctx === 'settings'} +

Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

+ {/if} {/if} diff --git a/frontend/src/routes/household/staples/page.test.ts b/frontend/src/routes/household/staples/page.test.ts index ecdb05c..d4e6080 100644 --- a/frontend/src/routes/household/staples/page.test.ts +++ b/frontend/src/routes/household/staples/page.test.ts @@ -106,4 +106,29 @@ describe('staples page — ctx=settings (D3)', () => { const backLink = screen.getByRole('link', { name: /← einstellungen/i }); expect(backLink).toHaveAttribute('href', '/settings'); }); + + it('renders hint text about autosave', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: 'settings' } } }); + expect(screen.getByText(/änderungen werden automatisch gespeichert/i)).toBeInTheDocument(); + }); + + it('renders hint text about next shopping list', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: 'settings' } } }); + expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument(); + }); +}); + +describe('staples page — hint text absent in onboarding', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('does not render hint text in onboarding context', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } }); + expect(screen.queryByText(/änderungen werden automatisch gespeichert/i)).not.toBeInTheDocument(); + }); }); -- 2.49.1 From 2ed5186ac8b6569a482f22f3edb24f3e3faba355 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:44:11 +0200 Subject: [PATCH 06/16] =?UTF-8?q?feat(nav):=20add=20extraPaths=20to=20NavI?= =?UTF-8?q?tem=20=E2=80=94=20Einstellungen=20active=20on=20/household/stap?= =?UTF-8?q?les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/nav.test.ts | 30 ++++++++++++++++++++++++++++++ frontend/src/lib/nav/nav.ts | 13 +++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/nav/nav.test.ts b/frontend/src/lib/nav/nav.test.ts index 0653e82..eff19e5 100644 --- a/frontend/src/lib/nav/nav.test.ts +++ b/frontend/src/lib/nav/nav.test.ts @@ -56,5 +56,35 @@ describe('nav config', () => { it('does not match unrelated route', () => { expect(isActiveRoute('/planner', '/recipes')).toBe(false); }); + + it('matches when pathname is in extraPaths', () => { + expect(isActiveRoute('/settings', '/household/staples', ['/household/staples'])).toBe(true); + }); + + it('matches sub-route of extraPath', () => { + expect(isActiveRoute('/settings', '/household/staples/edit', ['/household/staples'])).toBe(true); + }); + + it('does not match extraPath with similar prefix', () => { + expect(isActiveRoute('/settings', '/household/staples-old', ['/household/staples'])).toBe(false); + }); + + it('returns false when extraPaths provided but no match', () => { + expect(isActiveRoute('/settings', '/members', ['/household/staples'])).toBe(false); + }); + }); + + describe('NavItem extraPaths', () => { + it('Einstellungen desktop nav item includes /household/staples in extraPaths', () => { + const einstellungen = desktopNavSections + .flatMap((s) => s.items) + .find((i) => i.href === '/settings'); + expect(einstellungen?.extraPaths).toContain('/household/staples'); + }); + + it('Einstellungen mobile nav item includes /household/staples in extraPaths', () => { + const einstellungen = mobileNavItems.find((i) => i.href === '/settings'); + expect(einstellungen?.extraPaths).toContain('/household/staples'); + }); }); }); diff --git a/frontend/src/lib/nav/nav.ts b/frontend/src/lib/nav/nav.ts index 883bdcb..76657e6 100644 --- a/frontend/src/lib/nav/nav.ts +++ b/frontend/src/lib/nav/nav.ts @@ -2,6 +2,7 @@ export interface NavItem { href: string; label: string; icon: string; + extraPaths?: string[]; } export interface NavSection { @@ -13,11 +14,15 @@ export const mobileNavItems: NavItem[] = [ { href: '/planner', label: 'Planer', icon: '📅' }, { href: '/recipes', label: 'Rezepte', icon: '📖' }, { href: '/shopping', label: 'Einkauf', icon: '🛒' }, - { href: '/settings', label: 'Einstellungen', icon: '⚙️' } + { href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] } ]; -export function isActiveRoute(href: string, pathname: string): boolean { - return pathname === href || pathname.startsWith(href + '/'); +export function isActiveRoute(href: string, pathname: string, extraPaths?: string[]): boolean { + if (pathname === href || pathname.startsWith(href + '/')) return true; + if (extraPaths) { + return extraPaths.some((p) => pathname === p || pathname.startsWith(p + '/')); + } + return false; } export const desktopNavSections: NavSection[] = [ @@ -33,7 +38,7 @@ export const desktopNavSections: NavSection[] = [ title: 'Haushalt', items: [ { href: '/members', label: 'Mitglieder', icon: '👥' }, - { href: '/settings', label: 'Einstellungen', icon: '⚙️' } + { href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] } ] } ]; -- 2.49.1 From b0fc9f55c1bf21161b0f4df6e07d635e78639998 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:45:32 +0200 Subject: [PATCH 07/16] feat(nav): pass extraPaths to isActiveRoute in DesktopSidebar and MobileTabBar Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/DesktopSidebar.svelte | 2 +- frontend/src/lib/nav/DesktopSidebar.test.ts | 15 +++++++++++++++ frontend/src/lib/nav/MobileTabBar.svelte | 2 +- frontend/src/lib/nav/MobileTabBar.test.ts | 15 +++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/nav/DesktopSidebar.svelte b/frontend/src/lib/nav/DesktopSidebar.svelte index 9865bab..328c88d 100644 --- a/frontend/src/lib/nav/DesktopSidebar.svelte +++ b/frontend/src/lib/nav/DesktopSidebar.svelte @@ -24,7 +24,7 @@ {section.title}

{#each section.items as item (item.href)} - {@const active = isActiveRoute(item.href, $page.url.pathname)} + {@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)} { expect(widget).toBeInTheDocument(); }); }); + +describe('DesktopSidebar — extraPaths active state', () => { + it('marks Einstellungen active when on /household/staples', async () => { + const { readable } = await import('svelte/store'); + vi.doMock('$app/stores', () => ({ + page: readable({ url: new URL('http://localhost/household/staples') }) + })); + vi.resetModules(); + const { render: r, screen: s } = await import('@testing-library/svelte'); + const { default: Sidebar } = await import('./DesktopSidebar.svelte'); + r(Sidebar, { props: { appName: 'Test', householdName: 'Test' } }); + const link = s.getByRole('link', { name: /einstellungen/i }); + expect(link).toHaveAttribute('aria-current', 'page'); + }); +}); diff --git a/frontend/src/lib/nav/MobileTabBar.svelte b/frontend/src/lib/nav/MobileTabBar.svelte index d0b336a..a3ff8ac 100644 --- a/frontend/src/lib/nav/MobileTabBar.svelte +++ b/frontend/src/lib/nav/MobileTabBar.svelte @@ -8,7 +8,7 @@ class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden" > {#each mobileNavItems as item (item.href)} - {@const active = isActiveRoute(item.href, $page.url.pathname)} + {@const active = isActiveRoute(item.href, $page.url.pathname, item.extraPaths)} { expect(recipesLink).not.toHaveAttribute('aria-current'); }); }); + +describe('MobileTabBar — extraPaths active state', () => { + it('marks Einstellungen active when on /household/staples', async () => { + const { readable } = await import('svelte/store'); + vi.doMock('$app/stores', () => ({ + page: readable({ url: new URL('http://localhost/household/staples') }) + })); + vi.resetModules(); + const { render: r, screen: s } = await import('@testing-library/svelte'); + const { default: TabBar } = await import('./MobileTabBar.svelte'); + r(TabBar); + const link = s.getByRole('link', { name: /einstellungen/i }); + expect(link).toHaveAttribute('aria-current', 'page'); + }); +}); -- 2.49.1 From 824bb9445ff3b063bd510923096a2e84c631d7f2 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:47:25 +0200 Subject: [PATCH 08/16] =?UTF-8?q?refactor(staples):=20move=20household/sta?= =?UTF-8?q?ples=20route=20into=20(app)=20group=20=E2=80=94=20adds=20sideba?= =?UTF-8?q?r=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding context uses fixed inset overlay to cover AppShell. Settings context inherits AppShell layout by default. Co-Authored-By: Claude Sonnet 4.6 --- .../routes/{ => (app)}/household/staples/+page.server.ts | 0 .../src/routes/{ => (app)}/household/staples/+page.svelte | 6 +++--- .../src/routes/{ => (app)}/household/staples/+server.ts | 0 .../{ => (app)}/household/staples/page.server.test.ts | 0 .../src/routes/{ => (app)}/household/staples/page.test.ts | 0 .../src/routes/{ => (app)}/household/staples/server.test.ts | 0 6 files changed, 3 insertions(+), 3 deletions(-) rename frontend/src/routes/{ => (app)}/household/staples/+page.server.ts (100%) rename frontend/src/routes/{ => (app)}/household/staples/+page.svelte (92%) rename frontend/src/routes/{ => (app)}/household/staples/+server.ts (100%) rename frontend/src/routes/{ => (app)}/household/staples/page.server.test.ts (100%) rename frontend/src/routes/{ => (app)}/household/staples/page.test.ts (100%) rename frontend/src/routes/{ => (app)}/household/staples/server.test.ts (100%) diff --git a/frontend/src/routes/household/staples/+page.server.ts b/frontend/src/routes/(app)/household/staples/+page.server.ts similarity index 100% rename from frontend/src/routes/household/staples/+page.server.ts rename to frontend/src/routes/(app)/household/staples/+page.server.ts diff --git a/frontend/src/routes/household/staples/+page.svelte b/frontend/src/routes/(app)/household/staples/+page.svelte similarity index 92% rename from frontend/src/routes/household/staples/+page.svelte rename to frontend/src/routes/(app)/household/staples/+page.svelte index a8012a2..2dca2f2 100644 --- a/frontend/src/routes/household/staples/+page.svelte +++ b/frontend/src/routes/(app)/household/staples/+page.svelte @@ -14,7 +14,7 @@ {#if isOnboarding} -
+
{:else} -
+
{#if data.ctx === 'settings'} - ← Einstellungen + ← Einstellungen {/if}

Vorräte

diff --git a/frontend/src/routes/household/staples/+server.ts b/frontend/src/routes/(app)/household/staples/+server.ts similarity index 100% rename from frontend/src/routes/household/staples/+server.ts rename to frontend/src/routes/(app)/household/staples/+server.ts diff --git a/frontend/src/routes/household/staples/page.server.test.ts b/frontend/src/routes/(app)/household/staples/page.server.test.ts similarity index 100% rename from frontend/src/routes/household/staples/page.server.test.ts rename to frontend/src/routes/(app)/household/staples/page.server.test.ts diff --git a/frontend/src/routes/household/staples/page.test.ts b/frontend/src/routes/(app)/household/staples/page.test.ts similarity index 100% rename from frontend/src/routes/household/staples/page.test.ts rename to frontend/src/routes/(app)/household/staples/page.test.ts diff --git a/frontend/src/routes/household/staples/server.test.ts b/frontend/src/routes/(app)/household/staples/server.test.ts similarity index 100% rename from frontend/src/routes/household/staples/server.test.ts rename to frontend/src/routes/(app)/household/staples/server.test.ts -- 2.49.1 From ef39a97f57e6de4d4cb6520193c7191665f4e73e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 16:49:56 +0200 Subject: [PATCH 09/16] =?UTF-8?q?fix(settings):=20align=20hub=20page=20wit?= =?UTF-8?q?h=20V2=20spec=20=E2=80=94=20padding,=20H1,=20card=20radius/padd?= =?UTF-8?q?ing/sizes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Content area: p-[16px_20px] md:p-[40px_56px], max-w-[820px] grid - H1: display font, 28px, weight 500, tracking -0.02em - Cards: --radius-xl, p-[28px], shadow-card - Card title: 16px (was 14px) - Stat number: 28px font-light tracking-[-0.02em] (was 36px) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/SettingsCard.svelte | 4 ++-- frontend/src/routes/(app)/settings/+page.svelte | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/SettingsCard.svelte b/frontend/src/lib/components/SettingsCard.svelte index 404cf8c..c09dc8f 100644 --- a/frontend/src/lib/components/SettingsCard.svelte +++ b/frontend/src/lib/components/SettingsCard.svelte @@ -13,10 +13,10 @@ - + {title} diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index 10787d0..637a066 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -12,21 +12,22 @@ let { data }: Props = $props(); -

Einstellungen

+
{:else}
- {#if data.ctx === 'settings'} + {#if !isOnboarding} ← Einstellungen {/if}

Vorräte

- {#if data.ctx === 'settings'} + {#if !isOnboarding}

Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

{/if}
diff --git a/frontend/src/routes/(app)/household/staples/page.test.ts b/frontend/src/routes/(app)/household/staples/page.test.ts index d4e6080..855954c 100644 --- a/frontend/src/routes/(app)/household/staples/page.test.ts +++ b/frontend/src/routes/(app)/household/staples/page.test.ts @@ -80,51 +80,31 @@ describe('staples page — settings context (no ctx)', () => { expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument(); }); - it('does not render back-link when ctx is null', () => { + it('renders back-link "← Einstellungen" when ctx is null (default settings view)', () => { render(Page, { props: { data: { categories: mockCategories, ctx: null } } }); - expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument(); - }); -}); - -describe('staples page — ctx=settings (D3)', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('renders back-link "← Einstellungen" when ctx=settings', () => { - render(Page, { props: { data: { categories: mockCategories, ctx: 'settings' } } }); const backLink = screen.getByRole('link', { name: /← einstellungen/i }); expect(backLink).toBeInTheDocument(); }); it('back-link points to /settings', () => { - render(Page, { props: { data: { categories: mockCategories, ctx: 'settings' } } }); + render(Page, { props: { data: { categories: mockCategories, ctx: null } } }); const backLink = screen.getByRole('link', { name: /← einstellungen/i }); expect(backLink).toHaveAttribute('href', '/settings'); }); it('renders hint text about autosave', () => { - render(Page, { props: { data: { categories: mockCategories, ctx: 'settings' } } }); + render(Page, { props: { data: { categories: mockCategories, ctx: null } } }); expect(screen.getByText(/änderungen werden automatisch gespeichert/i)).toBeInTheDocument(); }); it('renders hint text about next shopping list', () => { - render(Page, { props: { data: { categories: mockCategories, ctx: 'settings' } } }); + render(Page, { props: { data: { categories: mockCategories, ctx: null } } }); expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument(); }); -}); -describe('staples page — hint text absent in onboarding', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true })); - }); - - afterEach(() => { - vi.unstubAllGlobals(); + it('does not render back-link in onboarding context', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } }); + expect(screen.queryByRole('link', { name: /einstellungen/i })).not.toBeInTheDocument(); }); it('does not render hint text in onboarding context', () => { diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index 637a066..cc27ca9 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -18,7 +18,7 @@
Vorräte diff --git a/frontend/src/routes/(app)/settings/page.test.ts b/frontend/src/routes/(app)/settings/page.test.ts index c54d313..22612e1 100644 --- a/frontend/src/routes/(app)/settings/page.test.ts +++ b/frontend/src/routes/(app)/settings/page.test.ts @@ -17,10 +17,10 @@ describe('settings page — hub', () => { expect(screen.getByRole('heading', { name: /einstellungen/i })).toBeInTheDocument(); }); - it('renders Vorräte card linking to /household/staples?ctx=settings', () => { + it('renders Vorräte card linking to /household/staples', () => { render(Page, { props: { data: makeData() } }); const links = screen.getAllByRole('link'); - const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples?ctx=settings'); + const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples'); expect(vorrateLink).toBeInTheDocument(); }); -- 2.49.1 From dde78baa84fb02fc7c7588fc807f777e74efb9e6 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 17:07:30 +0200 Subject: [PATCH 11/16] =?UTF-8?q?fix(settings):=20remove=20green=20left=20?= =?UTF-8?q?border=20from=20Vorr=C3=A4te=20card=20=E2=80=94=20no=20active?= =?UTF-8?q?=20state=20on=20hub=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/settings/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index cc27ca9..e795674 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -19,7 +19,7 @@ Vorräte -- 2.49.1 From af275642b0c033b0b17672a35b9c9088a5219724 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 17:22:20 +0200 Subject: [PATCH 12/16] refactor(staples): remove redundant {#if !isOnboarding} guards in else block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outer {:else} already guarantees isOnboarding is false — inner guards were always-true dead conditions unreachable by tests. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/household/staples/+page.svelte | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/(app)/household/staples/+page.svelte b/frontend/src/routes/(app)/household/staples/+page.svelte index 6d896b8..558c649 100644 --- a/frontend/src/routes/(app)/household/staples/+page.svelte +++ b/frontend/src/routes/(app)/household/staples/+page.svelte @@ -45,13 +45,9 @@
{:else}
- {#if !isOnboarding} - ← Einstellungen - {/if} + ← Einstellungen

Vorräte

- {#if !isOnboarding} -

Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

- {/if} +

Änderungen werden automatisch gespeichert. Gilt ab der nächsten Einkaufsliste.

{/if} -- 2.49.1 From 98c8aa9610745e55b497dfedf7e678f0e15e9f80 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 17:22:54 +0200 Subject: [PATCH 13/16] refactor(settings): remove dead accent prop from SettingsCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The green left border was removed from the design — the accent prop, data-accent attribute, and inline style were never used on the hub page. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/components/SettingsCard.svelte | 5 +---- frontend/src/lib/components/SettingsCard.test.ts | 15 ++------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/frontend/src/lib/components/SettingsCard.svelte b/frontend/src/lib/components/SettingsCard.svelte index c09dc8f..f16a95b 100644 --- a/frontend/src/lib/components/SettingsCard.svelte +++ b/frontend/src/lib/components/SettingsCard.svelte @@ -4,17 +4,14 @@ href: string; cta: string; meta?: string; - accent?: boolean; } - let { title, href, cta, meta, accent = false }: Props = $props(); + let { title, href, cta, meta }: Props = $props(); {title} diff --git a/frontend/src/lib/components/SettingsCard.test.ts b/frontend/src/lib/components/SettingsCard.test.ts index 73d53e2..be8fa13 100644 --- a/frontend/src/lib/components/SettingsCard.test.ts +++ b/frontend/src/lib/components/SettingsCard.test.ts @@ -9,9 +9,9 @@ describe('SettingsCard', () => { }); it('renders as an anchor tag with the given href', () => { - render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples?ctx=settings', cta: 'Bearbeiten →' } }); + render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Bearbeiten →' } }); const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', '/household/staples?ctx=settings'); + expect(link).toHaveAttribute('href', '/household/staples'); }); it('renders the cta text', () => { @@ -29,15 +29,4 @@ describe('SettingsCard', () => { expect(screen.queryByTestId('card-meta')).not.toBeInTheDocument(); }); - it('applies accent border style when accent=true', () => { - render(SettingsCard, { props: { title: 'Vorräte', href: '/household/staples', cta: 'Bearbeiten →', accent: true } }); - const link = screen.getByRole('link'); - expect(link).toHaveAttribute('data-accent', 'true'); - }); - - it('does not apply accent when accent is not set', () => { - render(SettingsCard, { props: { title: 'Haushalt', href: '/members', cta: 'Anzeigen →' } }); - const link = screen.getByRole('link'); - expect(link).not.toHaveAttribute('data-accent', 'true'); - }); }); -- 2.49.1 From d66120b19174606e8ab122d1937d8d3297eb28ed Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 17:23:22 +0200 Subject: [PATCH 14/16] refactor(settings): replace hardcoded #C0BFB8 with --color-border-hover token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add --color-border-hover to the design system neutrals and replace the hardcoded hex in all three card definitions (settings hub ×2, SettingsCard). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.css | 1 + frontend/src/lib/components/SettingsCard.svelte | 2 +- frontend/src/routes/(app)/settings/+page.svelte | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index da8a085..d90dd25 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -11,6 +11,7 @@ --color-surface: #f5f4ee; --color-subtle: #edecea; --color-border: #d8d7d0; + --color-border-hover: #c0bfb8; --color-text: #1c1c18; --color-text-muted: #6b6a63; diff --git a/frontend/src/lib/components/SettingsCard.svelte b/frontend/src/lib/components/SettingsCard.svelte index f16a95b..78dc31d 100644 --- a/frontend/src/lib/components/SettingsCard.svelte +++ b/frontend/src/lib/components/SettingsCard.svelte @@ -11,7 +11,7 @@ {title} diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index e795674..ed4c2e3 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -19,7 +19,7 @@ Vorräte @@ -48,7 +48,7 @@ Haushalt -- 2.49.1 From 5904102b1acc0cc2f86b759461c2d504c8379d63 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 17:23:31 +0200 Subject: [PATCH 15/16] refactor(settings): document benutzer non-null assertion in page.server.ts Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/settings/+page.server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/routes/(app)/settings/+page.server.ts b/frontend/src/routes/(app)/settings/+page.server.ts index 127f8c9..595b303 100644 --- a/frontend/src/routes/(app)/settings/+page.server.ts +++ b/frontend/src/routes/(app)/settings/+page.server.ts @@ -15,6 +15,7 @@ export const load: PageServerLoad = async ({ fetch, locals }) => { return { stapleCount, memberCount, + // hooks.server.ts guarantees benutzer is set for all (app) routes userName: locals.benutzer!.name }; }; -- 2.49.1 From 27163e3d72c95b685fd8baaee9ad1845abb392ed Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 17:32:37 +0200 Subject: [PATCH 16/16] feat(nav): remove Mitglieder link from desktop sidebar Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/AppShell.test.ts | 4 ++-- frontend/src/lib/nav/DesktopSidebar.test.ts | 8 ++++---- frontend/src/lib/nav/nav.test.ts | 4 ++-- frontend/src/lib/nav/nav.ts | 1 - 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/nav/AppShell.test.ts b/frontend/src/lib/nav/AppShell.test.ts index 24e09b4..bbfa139 100644 --- a/frontend/src/lib/nav/AppShell.test.ts +++ b/frontend/src/lib/nav/AppShell.test.ts @@ -31,7 +31,7 @@ describe('AppShell', () => { it('renders all navigation links from all nav variants', () => { render(AppShell, { props: defaultProps }); const links = screen.getAllByRole('link'); - // Mobile: 4, Tablet: 4, Desktop: 5 = 13 total - expect(links).toHaveLength(13); + // Mobile: 4, Tablet: 4, Desktop: 4 = 12 total + expect(links).toHaveLength(12); }); }); diff --git a/frontend/src/lib/nav/DesktopSidebar.test.ts b/frontend/src/lib/nav/DesktopSidebar.test.ts index b784791..b5bf988 100644 --- a/frontend/src/lib/nav/DesktopSidebar.test.ts +++ b/frontend/src/lib/nav/DesktopSidebar.test.ts @@ -28,17 +28,17 @@ describe('DesktopSidebar', () => { expect(screen.getByText('Einkauf')).toBeInTheDocument(); }); - it('renders Household section with 2 items', () => { + it('renders Household section with Einstellungen', () => { render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); expect(screen.getByText('Haushalt')).toBeInTheDocument(); - expect(screen.getByText('Mitglieder')).toBeInTheDocument(); expect(screen.getByText('Einstellungen')).toBeInTheDocument(); + expect(screen.queryByText('Mitglieder')).not.toBeInTheDocument(); }); - it('has 5 navigation links total', () => { + it('has 4 navigation links total', () => { render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); const links = screen.getAllByRole('link'); - expect(links).toHaveLength(5); + expect(links).toHaveLength(4); }); it('marks active item with aria-current="page"', () => { diff --git a/frontend/src/lib/nav/nav.test.ts b/frontend/src/lib/nav/nav.test.ts index eff19e5..62b579e 100644 --- a/frontend/src/lib/nav/nav.test.ts +++ b/frontend/src/lib/nav/nav.test.ts @@ -34,9 +34,9 @@ describe('nav config', () => { expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']); }); - it('Household section has Members, Settings', () => { + it('Household section has Settings', () => { const labels = desktopNavSections[1].items.map((item) => item.label); - expect(labels).toEqual(['Mitglieder', 'Einstellungen']); + expect(labels).toEqual(['Einstellungen']); }); }); diff --git a/frontend/src/lib/nav/nav.ts b/frontend/src/lib/nav/nav.ts index 76657e6..16fc317 100644 --- a/frontend/src/lib/nav/nav.ts +++ b/frontend/src/lib/nav/nav.ts @@ -37,7 +37,6 @@ export const desktopNavSections: NavSection[] = [ { title: 'Haushalt', items: [ - { href: '/members', label: 'Mitglieder', icon: '👥' }, { href: '/settings', label: 'Einstellungen', icon: '⚙️', extraPaths: ['/household/staples'] } ] } -- 2.49.1