From 7ae1f3dc18b236a9c243ff6ff00cdece4c670e93 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:09:26 +0200 Subject: [PATCH 01/19] feat(nav): add shared navigation config with mobile and desktop items Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/nav.test.ts | 42 ++++++++++++++++++++++++++++++++ frontend/src/lib/nav/nav.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 frontend/src/lib/nav/nav.test.ts create mode 100644 frontend/src/lib/nav/nav.ts diff --git a/frontend/src/lib/nav/nav.test.ts b/frontend/src/lib/nav/nav.test.ts new file mode 100644 index 0000000..63177d0 --- /dev/null +++ b/frontend/src/lib/nav/nav.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { mobileNavItems, desktopNavSections } from './nav'; + +describe('nav config', () => { + describe('mobileNavItems', () => { + it('has exactly 4 items', () => { + expect(mobileNavItems).toHaveLength(4); + }); + + it('contains Planner, Recipes, Shopping, Settings in order', () => { + const labels = mobileNavItems.map((item) => item.label); + expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf', 'Einstellungen']); + }); + + it('each item has href, label, and icon', () => { + for (const item of mobileNavItems) { + expect(item).toHaveProperty('href'); + expect(item).toHaveProperty('label'); + expect(item).toHaveProperty('icon'); + expect(item.href).toMatch(/^\//); + } + }); + }); + + describe('desktopNavSections', () => { + it('has 2 sections: Plan and Household', () => { + expect(desktopNavSections).toHaveLength(2); + expect(desktopNavSections[0].title).toBe('Plan'); + expect(desktopNavSections[1].title).toBe('Haushalt'); + }); + + it('Plan section has Planner, Recipes, Shopping', () => { + const labels = desktopNavSections[0].items.map((item) => item.label); + expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']); + }); + + it('Household section has Members, Settings', () => { + const labels = desktopNavSections[1].items.map((item) => item.label); + expect(labels).toEqual(['Mitglieder', 'Einstellungen']); + }); + }); +}); diff --git a/frontend/src/lib/nav/nav.ts b/frontend/src/lib/nav/nav.ts new file mode 100644 index 0000000..191971e --- /dev/null +++ b/frontend/src/lib/nav/nav.ts @@ -0,0 +1,35 @@ +export interface NavItem { + href: string; + label: string; + icon: string; +} + +export interface NavSection { + title: string; + items: NavItem[]; +} + +export const mobileNavItems: NavItem[] = [ + { href: '/planner', label: 'Planer', icon: 'calendar' }, + { href: '/recipes', label: 'Rezepte', icon: 'book' }, + { href: '/shopping', label: 'Einkauf', icon: 'shopping-cart' }, + { href: '/settings', label: 'Einstellungen', icon: 'settings' } +]; + +export const desktopNavSections: NavSection[] = [ + { + title: 'Plan', + items: [ + { href: '/planner', label: 'Planer', icon: 'calendar' }, + { href: '/recipes', label: 'Rezepte', icon: 'book' }, + { href: '/shopping', label: 'Einkauf', icon: 'shopping-cart' } + ] + }, + { + title: 'Haushalt', + items: [ + { href: '/members', label: 'Mitglieder', icon: 'users' }, + { href: '/settings', label: 'Einstellungen', icon: 'settings' } + ] + } +]; -- 2.49.1 From d3fa8991feb90d143bea73eb861da8492abf310f Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:12:04 +0200 Subject: [PATCH 02/19] feat(nav): add MobileTabBar with active state and safe-area padding Fixed vitest resolve conditions to use browser entry for Svelte 5. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/MobileTabBar.svelte | 22 +++++++++++ frontend/src/lib/nav/MobileTabBar.test.ts | 47 +++++++++++++++++++++++ frontend/vite.config.ts | 16 ++++++++ 3 files changed, 85 insertions(+) create mode 100644 frontend/src/lib/nav/MobileTabBar.svelte create mode 100644 frontend/src/lib/nav/MobileTabBar.test.ts create mode 100644 frontend/vite.config.ts diff --git a/frontend/src/lib/nav/MobileTabBar.svelte b/frontend/src/lib/nav/MobileTabBar.svelte new file mode 100644 index 0000000..27f8a39 --- /dev/null +++ b/frontend/src/lib/nav/MobileTabBar.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/nav/MobileTabBar.test.ts b/frontend/src/lib/nav/MobileTabBar.test.ts new file mode 100644 index 0000000..a0f5a29 --- /dev/null +++ b/frontend/src/lib/nav/MobileTabBar.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import MobileTabBar from './MobileTabBar.svelte'; + +vi.mock('$app/stores', () => { + const { readable } = require('svelte/store'); + return { + page: readable({ url: new URL('http://localhost/planner') }) + }; +}); + +describe('MobileTabBar', () => { + it('renders 4 nav items', () => { + render(MobileTabBar); + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(4); + }); + + it('renders correct labels', () => { + render(MobileTabBar); + expect(screen.getByText('Planer')).toBeInTheDocument(); + expect(screen.getByText('Rezepte')).toBeInTheDocument(); + expect(screen.getByText('Einkauf')).toBeInTheDocument(); + expect(screen.getByText('Einstellungen')).toBeInTheDocument(); + }); + + it('links have correct hrefs', () => { + render(MobileTabBar); + const links = screen.getAllByRole('link'); + expect(links[0]).toHaveAttribute('href', '/planner'); + expect(links[1]).toHaveAttribute('href', '/recipes'); + expect(links[2]).toHaveAttribute('href', '/shopping'); + expect(links[3]).toHaveAttribute('href', '/settings'); + }); + + it('marks active item with aria-current="page"', () => { + render(MobileTabBar); + const plannerLink = screen.getByRole('link', { name: /planer/i }); + expect(plannerLink).toHaveAttribute('aria-current', 'page'); + }); + + it('non-active items do not have aria-current', () => { + render(MobileTabBar); + const recipesLink = screen.getByRole('link', { name: /rezepte/i }); + expect(recipesLink).not.toHaveAttribute('aria-current'); + }); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..0b805cb --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + test: { + include: ['src/**/*.{test,spec}.{js,ts}'], + globals: true, + environment: 'jsdom', + setupFiles: ['src/test-setup.ts'] + }, + resolve: { + conditions: ['browser'] + } +}); -- 2.49.1 From 8f33f469de776ed8e4d72e1c8128d40a0c337769 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:14:12 +0200 Subject: [PATCH 03/19] feat(nav): add TabletNavBar with horizontal pills and active state Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/TabletNavBar.svelte | 22 +++++++++++++ frontend/src/lib/nav/TabletNavBar.test.ts | 38 +++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 frontend/src/lib/nav/TabletNavBar.svelte create mode 100644 frontend/src/lib/nav/TabletNavBar.test.ts diff --git a/frontend/src/lib/nav/TabletNavBar.svelte b/frontend/src/lib/nav/TabletNavBar.svelte new file mode 100644 index 0000000..3e5e63f --- /dev/null +++ b/frontend/src/lib/nav/TabletNavBar.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/nav/TabletNavBar.test.ts b/frontend/src/lib/nav/TabletNavBar.test.ts new file mode 100644 index 0000000..3f20529 --- /dev/null +++ b/frontend/src/lib/nav/TabletNavBar.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import TabletNavBar from './TabletNavBar.svelte'; + +vi.mock('$app/stores', () => { + const { readable } = require('svelte/store'); + return { + page: readable({ url: new URL('http://localhost/planner') }) + }; +}); + +describe('TabletNavBar', () => { + it('renders 4 nav items', () => { + render(TabletNavBar); + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(4); + }); + + it('renders correct labels', () => { + render(TabletNavBar); + expect(screen.getByText('Planer')).toBeInTheDocument(); + expect(screen.getByText('Rezepte')).toBeInTheDocument(); + expect(screen.getByText('Einkauf')).toBeInTheDocument(); + expect(screen.getByText('Einstellungen')).toBeInTheDocument(); + }); + + it('marks active item with aria-current="page"', () => { + render(TabletNavBar); + const plannerLink = screen.getByRole('link', { name: /planer/i }); + expect(plannerLink).toHaveAttribute('aria-current', 'page'); + }); + + it('renders as horizontal pill navigation', () => { + render(TabletNavBar); + const nav = screen.getByLabelText('Hauptnavigation'); + expect(nav).toBeInTheDocument(); + }); +}); -- 2.49.1 From 56cfd137aa93a21bf7a6c8b3974fa8d8e681a51d Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:16:12 +0200 Subject: [PATCH 04/19] feat(nav): add DesktopSidebar with logo, nav sections, and variety widget slot Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/DesktopSidebar.svelte | 42 ++++++++++++++ frontend/src/lib/nav/DesktopSidebar.test.ts | 61 +++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 frontend/src/lib/nav/DesktopSidebar.svelte create mode 100644 frontend/src/lib/nav/DesktopSidebar.test.ts diff --git a/frontend/src/lib/nav/DesktopSidebar.svelte b/frontend/src/lib/nav/DesktopSidebar.svelte new file mode 100644 index 0000000..3812cb5 --- /dev/null +++ b/frontend/src/lib/nav/DesktopSidebar.svelte @@ -0,0 +1,42 @@ + + + diff --git a/frontend/src/lib/nav/DesktopSidebar.test.ts b/frontend/src/lib/nav/DesktopSidebar.test.ts new file mode 100644 index 0000000..0f5ef69 --- /dev/null +++ b/frontend/src/lib/nav/DesktopSidebar.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import DesktopSidebar from './DesktopSidebar.svelte'; + +vi.mock('$app/stores', () => { + const { readable } = require('svelte/store'); + return { + page: readable({ url: new URL('http://localhost/planner') }) + }; +}); + +describe('DesktopSidebar', () => { + it('renders the app name', () => { + render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); + expect(screen.getByText('Mealprep')).toBeInTheDocument(); + }); + + it('renders the household name', () => { + render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); + expect(screen.getByText('Familie Müller')).toBeInTheDocument(); + }); + + it('renders Plan section with 3 items', () => { + render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); + expect(screen.getByText('Plan')).toBeInTheDocument(); + expect(screen.getByText('Planer')).toBeInTheDocument(); + expect(screen.getByText('Rezepte')).toBeInTheDocument(); + expect(screen.getByText('Einkauf')).toBeInTheDocument(); + }); + + it('renders Household section with 2 items', () => { + render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); + expect(screen.getByText('Haushalt')).toBeInTheDocument(); + expect(screen.getByText('Mitglieder')).toBeInTheDocument(); + expect(screen.getByText('Einstellungen')).toBeInTheDocument(); + }); + + it('has 5 navigation links total', () => { + render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); + const links = screen.getAllByRole('link'); + expect(links).toHaveLength(5); + }); + + it('marks active item with aria-current="page"', () => { + render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); + const plannerLink = screen.getByRole('link', { name: /planer/i }); + expect(plannerLink).toHaveAttribute('aria-current', 'page'); + }); + + it('non-active items do not have aria-current', () => { + render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); + const recipesLink = screen.getByRole('link', { name: /rezepte/i }); + expect(recipesLink).not.toHaveAttribute('aria-current'); + }); + + it('renders a variety widget slot area', () => { + render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } }); + const widget = screen.getByTestId('variety-widget-slot'); + expect(widget).toBeInTheDocument(); + }); +}); -- 2.49.1 From cfe38c39aa1f42385606f77ac7f5a5a5c686af18 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:18:09 +0200 Subject: [PATCH 05/19] feat(nav): add AppShell layout with breakpoint-switched navigation Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/AppShell.svelte | 19 ++++++++++++++ frontend/src/lib/nav/AppShell.test.ts | 37 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 frontend/src/lib/nav/AppShell.svelte create mode 100644 frontend/src/lib/nav/AppShell.test.ts diff --git a/frontend/src/lib/nav/AppShell.svelte b/frontend/src/lib/nav/AppShell.svelte new file mode 100644 index 0000000..dba02e4 --- /dev/null +++ b/frontend/src/lib/nav/AppShell.svelte @@ -0,0 +1,19 @@ + + +
+ +
+ +
+ {@render children?.()} +
+ +
+
diff --git a/frontend/src/lib/nav/AppShell.test.ts b/frontend/src/lib/nav/AppShell.test.ts new file mode 100644 index 0000000..63e9eb0 --- /dev/null +++ b/frontend/src/lib/nav/AppShell.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import AppShell from './AppShell.svelte'; + +vi.mock('$app/stores', () => { + const { readable } = require('svelte/store'); + return { + page: readable({ url: new URL('http://localhost/planner') }) + }; +}); + +describe('AppShell', () => { + const defaultProps = { appName: 'Mealprep', householdName: 'Familie Müller' }; + + it('renders the DesktopSidebar', () => { + render(AppShell, { props: defaultProps }); + expect(screen.getByTestId('variety-widget-slot')).toBeInTheDocument(); + }); + + it('renders the MobileTabBar nav', () => { + render(AppShell, { props: defaultProps }); + const navs = screen.getAllByLabelText('Hauptnavigation'); + expect(navs.length).toBeGreaterThanOrEqual(2); + }); + + it('renders a main content area', () => { + render(AppShell, { props: defaultProps }); + expect(screen.getByRole('main')).toBeInTheDocument(); + }); + + 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); + }); +}); -- 2.49.1 From 7a17873046910172d471e3af5f77f800dddc86ea Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:19:40 +0200 Subject: [PATCH 06/19] feat(auth): add auth guard in hooks.server.ts with session validation Validates session cookie via GET /v1/auth/me, populates event.locals with benutzer and haushalt, redirects to /login if unauthenticated. Public routes (/login, /register, /invite) bypass auth. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.d.ts | 25 ++++++++ frontend/src/hooks.server.test.ts | 96 +++++++++++++++++++++++++++++++ frontend/src/hooks.server.ts | 40 +++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/hooks.server.test.ts create mode 100644 frontend/src/hooks.server.ts diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..6c9d3a6 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,25 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + interface Error { + meldung?: string; + } + interface Locals { + benutzer?: { + id: string; + name: string; + rolle: 'planer' | 'mitglied'; + }; + haushalt?: { + id: string; + name: string; + }; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts new file mode 100644 index 0000000..f56eadd --- /dev/null +++ b/frontend/src/hooks.server.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock $env/dynamic/private before importing anything +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +// Mock the apiClient +const mockGet = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet }) +})); + +describe('auth guard (hooks.server.ts handle)', () => { + let handle: any; + + beforeEach(async () => { + mockGet.mockReset(); + const mod = await import('./hooks.server'); + handle = mod.handle; + }); + + function createEvent(pathname: string, cookie?: string) { + const resolve = vi.fn().mockResolvedValue(new Response('ok')); + const event = { + url: new URL(`http://localhost${pathname}`), + cookies: { + get: vi.fn().mockReturnValue(cookie) + }, + locals: {} as any, + fetch: vi.fn() + }; + return { event, resolve }; + } + + it('allows public routes without auth', async () => { + const { event, resolve } = createEvent('/login'); + await handle({ event, resolve }); + expect(resolve).toHaveBeenCalledWith(event); + }); + + it('redirects unauthenticated requests on protected routes', async () => { + const { event, resolve } = createEvent('/planner'); + try { + await handle({ event, resolve }); + // If using SvelteKit redirect, it throws + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(302); + expect(e.location).toBe('/login'); + } + }); + + it('populates event.locals.benutzer on valid session', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + id: '123', + displayName: 'Max', + householdId: 'h1', + householdName: 'Familie Müller', + householdRole: 'planer', + email: 'max@example.com', + systemRole: 'user' + } + }, + error: undefined + }); + + const { event, resolve } = createEvent('/planner', 'valid-session'); + await handle({ event, resolve }); + expect(event.locals.benutzer).toEqual({ + id: '123', + name: 'Max', + rolle: 'planer' + }); + expect(event.locals.haushalt).toEqual({ + id: 'h1', + name: 'Familie Müller' + }); + expect(resolve).toHaveBeenCalledWith(event); + }); + + it('redirects to /login when session validation fails', async () => { + mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } }); + + const { event, resolve } = createEvent('/planner', 'bad-session'); + try { + await handle({ event, resolve }); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(302); + expect(e.location).toBe('/login'); + } + }); +}); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..4a690a1 --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,40 @@ +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; + +const PUBLIC_ROUTES = ['/login', '/register', '/invite']; + +function isPublicRoute(pathname: string): boolean { + return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/')); +} + +export const handle: Handle = async ({ event, resolve }) => { + if (isPublicRoute(event.url.pathname)) { + return resolve(event); + } + + const sessionCookie = event.cookies.get('session'); + if (!sessionCookie) { + redirect(302, '/login'); + } + + const api = apiClient(event.fetch); + const { data, error } = await api.GET('/v1/auth/me'); + + if (error || !data?.data) { + redirect(302, '/login'); + } + + const user = data.data; + event.locals.benutzer = { + id: user.id!, + name: user.displayName!, + rolle: user.householdRole as 'planer' | 'mitglied' + }; + event.locals.haushalt = { + id: user.householdId!, + name: user.householdName! + }; + + return resolve(event); +}; -- 2.49.1 From 9626bde6946dc6e9438112f0071ac07eb53e6500 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:22:34 +0200 Subject: [PATCH 07/19] feat(shell): add route groups, layout server load, redirect, and placeholder pages - (app) group with AppShell layout, loads user/household from locals - (public) group with full-viewport split layout, /login placeholder - Root / redirects to /planner for authenticated users - Placeholder stubs for planner, recipes, shopping, settings, members Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/AppShell.svelte | 2 +- frontend/src/routes/(app)/+layout.server.ts | 8 ++++++++ frontend/src/routes/(app)/+layout.svelte | 9 +++++++++ frontend/src/routes/(app)/members/+page.svelte | 1 + frontend/src/routes/(app)/planner/+page.svelte | 1 + frontend/src/routes/(app)/recipes/+page.svelte | 1 + frontend/src/routes/(app)/settings/+page.svelte | 1 + frontend/src/routes/(app)/shopping/+page.svelte | 1 + frontend/src/routes/(public)/+layout.svelte | 12 ++++++++++++ frontend/src/routes/(public)/login/+page.svelte | 2 ++ frontend/src/routes/+page.server.ts | 6 ++++++ frontend/src/routes/+page.svelte | 1 + 12 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 frontend/src/routes/(app)/+layout.server.ts create mode 100644 frontend/src/routes/(app)/+layout.svelte create mode 100644 frontend/src/routes/(app)/members/+page.svelte create mode 100644 frontend/src/routes/(app)/planner/+page.svelte create mode 100644 frontend/src/routes/(app)/recipes/+page.svelte create mode 100644 frontend/src/routes/(app)/settings/+page.svelte create mode 100644 frontend/src/routes/(app)/shopping/+page.svelte create mode 100644 frontend/src/routes/(public)/+layout.svelte create mode 100644 frontend/src/routes/(public)/login/+page.svelte create mode 100644 frontend/src/routes/+page.server.ts create mode 100644 frontend/src/routes/+page.svelte diff --git a/frontend/src/lib/nav/AppShell.svelte b/frontend/src/lib/nav/AppShell.svelte index dba02e4..e2c8b21 100644 --- a/frontend/src/lib/nav/AppShell.svelte +++ b/frontend/src/lib/nav/AppShell.svelte @@ -4,7 +4,7 @@ import TabletNavBar from './TabletNavBar.svelte'; import DesktopSidebar from './DesktopSidebar.svelte'; - let { appName, householdName, children }: { appName: string; householdName: string; children: Snippet } = $props(); + let { appName, householdName, children }: { appName: string; householdName: string; children?: Snippet } = $props();
diff --git a/frontend/src/routes/(app)/+layout.server.ts b/frontend/src/routes/(app)/+layout.server.ts new file mode 100644 index 0000000..e64b781 --- /dev/null +++ b/frontend/src/routes/(app)/+layout.server.ts @@ -0,0 +1,8 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { + benutzer: locals.benutzer!, + haushalt: locals.haushalt! + }; +}; diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..9f67711 --- /dev/null +++ b/frontend/src/routes/(app)/+layout.svelte @@ -0,0 +1,9 @@ + + + + {@render children()} + diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte new file mode 100644 index 0000000..a4722af --- /dev/null +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -0,0 +1 @@ +

Mitglieder

diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte new file mode 100644 index 0000000..dbb8271 --- /dev/null +++ b/frontend/src/routes/(app)/planner/+page.svelte @@ -0,0 +1 @@ +

Planer

diff --git a/frontend/src/routes/(app)/recipes/+page.svelte b/frontend/src/routes/(app)/recipes/+page.svelte new file mode 100644 index 0000000..1faa0a3 --- /dev/null +++ b/frontend/src/routes/(app)/recipes/+page.svelte @@ -0,0 +1 @@ +

Rezepte

diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte new file mode 100644 index 0000000..f369397 --- /dev/null +++ b/frontend/src/routes/(app)/settings/+page.svelte @@ -0,0 +1 @@ +

Einstellungen

diff --git a/frontend/src/routes/(app)/shopping/+page.svelte b/frontend/src/routes/(app)/shopping/+page.svelte new file mode 100644 index 0000000..158e40e --- /dev/null +++ b/frontend/src/routes/(app)/shopping/+page.svelte @@ -0,0 +1 @@ +

Einkaufsliste

diff --git a/frontend/src/routes/(public)/+layout.svelte b/frontend/src/routes/(public)/+layout.svelte new file mode 100644 index 0000000..37af5a4 --- /dev/null +++ b/frontend/src/routes/(public)/+layout.svelte @@ -0,0 +1,12 @@ + + +
+ +
+ {@render children()} +
+
diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte new file mode 100644 index 0000000..7b4a440 --- /dev/null +++ b/frontend/src/routes/(public)/login/+page.svelte @@ -0,0 +1,2 @@ +

Anmelden

+

Login-Formular folgt.

diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts new file mode 100644 index 0000000..7985791 --- /dev/null +++ b/frontend/src/routes/+page.server.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + redirect(302, '/planner'); +}; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..36ec5ff --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1 @@ +

Weiterleitung...

-- 2.49.1 From db4b01ca771c0337861e2bb7f3dec6aefe96196a Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:52:23 +0200 Subject: [PATCH 08/19] refactor(config): document resolve.conditions safety for SSR builds Verified: SvelteKit's plugin overrides resolve.conditions for SSR builds. The global 'browser' condition only affects vitest and dev. Build output confirmed correct with npm run build. Co-Authored-By: Claude Sonnet 4.6 --- frontend/vite.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 0b805cb..5b032c7 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,6 +10,8 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['src/test-setup.ts'] }, + // Required for vitest: resolves Svelte to client entry (not server). + // SvelteKit's plugin overrides this for SSR builds — verified safe. resolve: { conditions: ['browser'] } -- 2.49.1 From 05bf66de56f4b7c2048247157380ec4c95c4324f Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:53:20 +0200 Subject: [PATCH 09/19] refactor(test): replace require() with import() in $app/stores mocks CJS require() is fragile in an ESM project. Use async import() instead. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/AppShell.test.ts | 4 ++-- frontend/src/lib/nav/DesktopSidebar.test.ts | 4 ++-- frontend/src/lib/nav/MobileTabBar.test.ts | 4 ++-- frontend/src/lib/nav/TabletNavBar.test.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/nav/AppShell.test.ts b/frontend/src/lib/nav/AppShell.test.ts index 63e9eb0..24e09b4 100644 --- a/frontend/src/lib/nav/AppShell.test.ts +++ b/frontend/src/lib/nav/AppShell.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import AppShell from './AppShell.svelte'; -vi.mock('$app/stores', () => { - const { readable } = require('svelte/store'); +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); return { page: readable({ url: new URL('http://localhost/planner') }) }; diff --git a/frontend/src/lib/nav/DesktopSidebar.test.ts b/frontend/src/lib/nav/DesktopSidebar.test.ts index 0f5ef69..16e2421 100644 --- a/frontend/src/lib/nav/DesktopSidebar.test.ts +++ b/frontend/src/lib/nav/DesktopSidebar.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import DesktopSidebar from './DesktopSidebar.svelte'; -vi.mock('$app/stores', () => { - const { readable } = require('svelte/store'); +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); return { page: readable({ url: new URL('http://localhost/planner') }) }; diff --git a/frontend/src/lib/nav/MobileTabBar.test.ts b/frontend/src/lib/nav/MobileTabBar.test.ts index a0f5a29..a830119 100644 --- a/frontend/src/lib/nav/MobileTabBar.test.ts +++ b/frontend/src/lib/nav/MobileTabBar.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import MobileTabBar from './MobileTabBar.svelte'; -vi.mock('$app/stores', () => { - const { readable } = require('svelte/store'); +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); return { page: readable({ url: new URL('http://localhost/planner') }) }; diff --git a/frontend/src/lib/nav/TabletNavBar.test.ts b/frontend/src/lib/nav/TabletNavBar.test.ts index 3f20529..2b32f43 100644 --- a/frontend/src/lib/nav/TabletNavBar.test.ts +++ b/frontend/src/lib/nav/TabletNavBar.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import TabletNavBar from './TabletNavBar.svelte'; -vi.mock('$app/stores', () => { - const { readable } = require('svelte/store'); +vi.mock('$app/stores', async () => { + const { readable } = await import('svelte/store'); return { page: readable({ url: new URL('http://localhost/planner') }) }; -- 2.49.1 From d7f317587eb388949644b9702ac6705e293242b8 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:53:56 +0200 Subject: [PATCH 10/19] refactor(public): add lang="ts" to public layout for consistency Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(public)/+layout.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/(public)/+layout.svelte b/frontend/src/routes/(public)/+layout.svelte index 37af5a4..d90949a 100644 --- a/frontend/src/routes/(public)/+layout.svelte +++ b/frontend/src/routes/(public)/+layout.svelte @@ -1,4 +1,4 @@ - -- 2.49.1 From 2bdb1010f8920df5188ba1410a6d3952e2c78e50 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:55:03 +0200 Subject: [PATCH 11/19] fix(auth): bypass auth guard for static assets and favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents redirect loop when backend is down — login page CSS/JS would otherwise be redirected to /login. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.test.ts | 9 +++++++++ frontend/src/hooks.server.ts | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index f56eadd..d1b16b6 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -39,6 +39,15 @@ describe('auth guard (hooks.server.ts handle)', () => { expect(resolve).toHaveBeenCalledWith(event); }); + it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])( + 'allows static asset %s without auth', + async (path) => { + const { event, resolve } = createEvent(path); + await handle({ event, resolve }); + expect(resolve).toHaveBeenCalledWith(event); + } + ); + it('redirects unauthenticated requests on protected routes', async () => { const { event, resolve } = createEvent('/planner'); try { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 4a690a1..6579fbc 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -4,7 +4,12 @@ import { apiClient } from '$lib/server/api'; const PUBLIC_ROUTES = ['/login', '/register', '/invite']; +const STATIC_PREFIXES = ['/_app/', '/favicon']; + function isPublicRoute(pathname: string): boolean { + if (STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) { + return true; + } return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/')); } -- 2.49.1 From cc74c0042af494f5cba64db7d7700b7f3328ad38 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:55:48 +0200 Subject: [PATCH 12/19] test(auth): add isPublicRoute boundary tests for sub-paths and trailing slash Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index d1b16b6..a57bcc3 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -39,6 +39,15 @@ describe('auth guard (hooks.server.ts handle)', () => { expect(resolve).toHaveBeenCalledWith(event); }); + it.each(['/login', '/login/', '/register', '/invite/abc123'])( + 'allows public route %s without auth', + async (path) => { + const { event, resolve } = createEvent(path); + await handle({ event, resolve }); + expect(resolve).toHaveBeenCalledWith(event); + } + ); + it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])( 'allows static asset %s without auth', async (path) => { -- 2.49.1 From 92c7d8f92e99c10d77e10feb90099435cfe93996 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:56:49 +0200 Subject: [PATCH 13/19] feat(auth): preserve redirect URL when redirecting to /login Appends ?redirect= with the original pathname so the login page can redirect back after successful authentication. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.test.ts | 11 +++++------ frontend/src/hooks.server.ts | 9 +++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index a57bcc3..3601f49 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -57,15 +57,14 @@ describe('auth guard (hooks.server.ts handle)', () => { } ); - it('redirects unauthenticated requests on protected routes', async () => { - const { event, resolve } = createEvent('/planner'); + it('redirects unauthenticated requests to /login with redirect param', async () => { + const { event, resolve } = createEvent('/recipes/abc'); try { await handle({ event, resolve }); - // If using SvelteKit redirect, it throws expect.unreachable(); } catch (e: any) { expect(e.status).toBe(302); - expect(e.location).toBe('/login'); + expect(e.location).toBe('/login?redirect=%2Frecipes%2Fabc'); } }); @@ -99,7 +98,7 @@ describe('auth guard (hooks.server.ts handle)', () => { expect(resolve).toHaveBeenCalledWith(event); }); - it('redirects to /login when session validation fails', async () => { + it('redirects to /login with redirect param when session validation fails', async () => { mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } }); const { event, resolve } = createEvent('/planner', 'bad-session'); @@ -108,7 +107,7 @@ describe('auth guard (hooks.server.ts handle)', () => { expect.unreachable(); } catch (e: any) { expect(e.status).toBe(302); - expect(e.location).toBe('/login'); + expect(e.location).toBe('/login?redirect=%2Fplanner'); } }); }); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 6579fbc..9150df4 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -13,6 +13,11 @@ function isPublicRoute(pathname: string): boolean { return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/')); } +function loginRedirect(pathname: string): never { + const target = '/login?redirect=' + encodeURIComponent(pathname); + redirect(302, target); +} + export const handle: Handle = async ({ event, resolve }) => { if (isPublicRoute(event.url.pathname)) { return resolve(event); @@ -20,14 +25,14 @@ export const handle: Handle = async ({ event, resolve }) => { const sessionCookie = event.cookies.get('session'); if (!sessionCookie) { - redirect(302, '/login'); + loginRedirect(event.url.pathname); } const api = apiClient(event.fetch); const { data, error } = await api.GET('/v1/auth/me'); if (error || !data?.data) { - redirect(302, '/login'); + loginRedirect(event.url.pathname); } const user = data.data; -- 2.49.1 From 32550377aab4b153106e21fd49a1f217cae95666 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:57:34 +0200 Subject: [PATCH 14/19] fix(auth): read JSESSIONID cookie to match Spring Security default Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.test.ts | 5 ++++- frontend/src/hooks.server.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 3601f49..b27342e 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -25,7 +25,10 @@ describe('auth guard (hooks.server.ts handle)', () => { const event = { url: new URL(`http://localhost${pathname}`), cookies: { - get: vi.fn().mockReturnValue(cookie) + get: vi.fn().mockImplementation((name: string) => { + if (name === 'JSESSIONID') return cookie; + return undefined; + }) }, locals: {} as any, fetch: vi.fn() diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 9150df4..afa1868 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -23,7 +23,7 @@ export const handle: Handle = async ({ event, resolve }) => { return resolve(event); } - const sessionCookie = event.cookies.get('session'); + const sessionCookie = event.cookies.get('JSESSIONID'); if (!sessionCookie) { loginRedirect(event.url.pathname); } -- 2.49.1 From aeaca7653476c24bb403df9ee97ce1fe89f02e5a Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 13:58:37 +0200 Subject: [PATCH 15/19] =?UTF-8?q?fix(auth):=20handle=20users=20without=20h?= =?UTF-8?q?ousehold=20=E2=80=94=20fallback=20to=20'Kein=20Haushalt'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes non-null assertions on householdId/householdName. Users who haven't joined a household get a fallback name in the sidebar. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/app.d.ts | 2 +- frontend/src/hooks.server.test.ts | 30 ++++++++++++++++++++++++++++++ frontend/src/hooks.server.ts | 6 +++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 6c9d3a6..d510808 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -12,7 +12,7 @@ declare global { rolle: 'planer' | 'mitglied'; }; haushalt?: { - id: string; + id: string | undefined; name: string; }; } diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index b27342e..708071e 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -101,6 +101,36 @@ describe('auth guard (hooks.server.ts handle)', () => { expect(resolve).toHaveBeenCalledWith(event); }); + it('handles user without household gracefully', async () => { + mockGet.mockResolvedValue({ + data: { + data: { + id: '456', + displayName: 'Neu', + householdId: null, + householdName: null, + householdRole: null, + email: 'neu@example.com', + systemRole: 'user' + } + }, + error: undefined + }); + + const { event, resolve } = createEvent('/planner', 'valid-session'); + await handle({ event, resolve }); + expect(event.locals.benutzer).toEqual({ + id: '456', + name: 'Neu', + rolle: 'mitglied' + }); + expect(event.locals.haushalt).toEqual({ + id: undefined, + name: 'Kein Haushalt' + }); + expect(resolve).toHaveBeenCalledWith(event); + }); + it('redirects to /login with redirect param when session validation fails', async () => { mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } }); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index afa1868..0f60418 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -39,11 +39,11 @@ export const handle: Handle = async ({ event, resolve }) => { event.locals.benutzer = { id: user.id!, name: user.displayName!, - rolle: user.householdRole as 'planer' | 'mitglied' + rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied' }; event.locals.haushalt = { - id: user.householdId!, - name: user.householdName! + id: user.householdId ?? undefined, + name: user.householdName ?? 'Kein Haushalt' }; return resolve(event); -- 2.49.1 From bd8e9016856265885ed0f81c10ae421d5235cb97 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Thu, 2 Apr 2026 14:00:18 +0200 Subject: [PATCH 16/19] fix(nav): use segment-boundary route matching to prevent false positives Extracts isActiveRoute() into shared nav module. Matches exact path or path + '/' prefix, preventing /settings from matching /settings-advanced. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/nav/DesktopSidebar.svelte | 4 ++-- frontend/src/lib/nav/MobileTabBar.svelte | 4 ++-- frontend/src/lib/nav/TabletNavBar.svelte | 4 ++-- frontend/src/lib/nav/nav.test.ts | 20 +++++++++++++++++++- frontend/src/lib/nav/nav.ts | 4 ++++ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/nav/DesktopSidebar.svelte b/frontend/src/lib/nav/DesktopSidebar.svelte index 3812cb5..ec7aa95 100644 --- a/frontend/src/lib/nav/DesktopSidebar.svelte +++ b/frontend/src/lib/nav/DesktopSidebar.svelte @@ -1,6 +1,6 @@ @@ -24,7 +24,7 @@ {section.title}

{#each section.items as item (item.href)} - {@const active = $page.url.pathname.startsWith(item.href)} + {@const active = isActiveRoute(item.href, $page.url.pathname)} import { page } from '$app/stores'; - import { mobileNavItems } from './nav'; + import { mobileNavItems, isActiveRoute } from './nav';