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 new file mode 100644 index 0000000..78dc31d --- /dev/null +++ b/frontend/src/lib/components/SettingsCard.svelte @@ -0,0 +1,29 @@ + + + + + {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..be8fa13 --- /dev/null +++ b/frontend/src/lib/components/SettingsCard.test.ts @@ -0,0 +1,32 @@ +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', cta: 'Bearbeiten →' } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/household/staples'); + }); + + 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(); + }); + +}); 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.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(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"', () => { @@ -59,3 +59,18 @@ describe('DesktopSidebar', () => { 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'); + }); +}); diff --git a/frontend/src/lib/nav/nav.test.ts b/frontend/src/lib/nav/nav.test.ts index 0653e82..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']); }); }); @@ -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..16fc317 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[] = [ @@ -32,8 +37,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'] } ] } ]; 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 79% rename from frontend/src/routes/household/staples/+page.svelte rename to frontend/src/routes/(app)/household/staples/+page.svelte index 5db2188..558c649 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} -
+
+ ← Einstellungen

Vorräte

+

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

{/if} 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 66% rename from frontend/src/routes/household/staples/page.test.ts rename to frontend/src/routes/(app)/household/staples/page.test.ts index 873bbc9..855954c 100644 --- a/frontend/src/routes/household/staples/page.test.ts +++ b/frontend/src/routes/(app)/household/staples/page.test.ts @@ -79,4 +79,36 @@ 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('renders back-link "← Einstellungen" when ctx is null (default settings view)', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: null } } }); + const backLink = screen.getByRole('link', { name: /← einstellungen/i }); + expect(backLink).toBeInTheDocument(); + }); + + it('back-link points to /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: 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: null } } }); + expect(screen.getByText(/gilt ab der nächsten einkaufsliste/i)).toBeInTheDocument(); + }); + + 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', () => { + render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } }); + expect(screen.queryByText(/änderungen werden automatisch gespeichert/i)).not.toBeInTheDocument(); + }); }); 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 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..595b303 --- /dev/null +++ b/frontend/src/routes/(app)/settings/+page.server.ts @@ -0,0 +1,21 @@ +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, + // hooks.server.ts guarantees benutzer is set for all (app) routes + userName: locals.benutzer!.name + }; +}; diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte index f369397..ed4c2e3 100644 --- a/frontend/src/routes/(app)/settings/+page.svelte +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -1 +1,72 @@ -

Einstellungen

+ + + 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); + }); +}); 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..22612e1 --- /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', () => { + render(Page, { props: { data: makeData() } }); + const links = screen.getAllByRole('link'); + const vorrateLink = links.find((l) => l.getAttribute('href') === '/household/staples'); + 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(); + }); +});