diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..d510808 --- /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 | undefined; + 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..708071e --- /dev/null +++ b/frontend/src/hooks.server.test.ts @@ -0,0 +1,146 @@ +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().mockImplementation((name: string) => { + if (name === 'JSESSIONID') return cookie; + return undefined; + }) + }, + 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.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) => { + const { event, resolve } = createEvent(path); + await handle({ event, resolve }); + expect(resolve).toHaveBeenCalledWith(event); + } + ); + + it('redirects unauthenticated requests to /login with redirect param', async () => { + const { event, resolve } = createEvent('/recipes/abc'); + try { + await handle({ event, resolve }); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(302); + expect(e.location).toBe('/login?redirect=%2Frecipes%2Fabc'); + } + }); + + 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('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 } }); + + 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?redirect=%2Fplanner'); + } + }); +}); diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..0f60418 --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,50 @@ +import type { Handle } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +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 + '/')); +} + +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); + } + + const sessionCookie = event.cookies.get('JSESSIONID'); + if (!sessionCookie) { + loginRedirect(event.url.pathname); + } + + const api = apiClient(event.fetch); + const { data, error } = await api.GET('/v1/auth/me'); + + if (error || !data?.data) { + loginRedirect(event.url.pathname); + } + + const user = data.data; + event.locals.benutzer = { + id: user.id!, + name: user.displayName!, + rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied' + }; + event.locals.haushalt = { + id: user.householdId ?? undefined, + name: user.householdName ?? 'Kein Haushalt' + }; + + return resolve(event); +}; diff --git a/frontend/src/lib/nav/AppShell.svelte b/frontend/src/lib/nav/AppShell.svelte new file mode 100644 index 0000000..e2c8b21 --- /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..24e09b4 --- /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', async () => { + const { readable } = await import('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); + }); +}); diff --git a/frontend/src/lib/nav/DesktopSidebar.svelte b/frontend/src/lib/nav/DesktopSidebar.svelte new file mode 100644 index 0000000..9865bab --- /dev/null +++ b/frontend/src/lib/nav/DesktopSidebar.svelte @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/lib/nav/DesktopSidebar.test.ts b/frontend/src/lib/nav/DesktopSidebar.test.ts new file mode 100644 index 0000000..16e2421 --- /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', async () => { + const { readable } = await import('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(); + }); +}); diff --git a/frontend/src/lib/nav/MobileTabBar.routes.test.ts b/frontend/src/lib/nav/MobileTabBar.routes.test.ts new file mode 100644 index 0000000..36b73a7 --- /dev/null +++ b/frontend/src/lib/nav/MobileTabBar.routes.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; + +const { pageStore } = vi.hoisted(() => { + const { writable } = require('svelte/store'); + const pageStore = writable({ url: new URL('http://localhost/planner') }); + return { pageStore }; +}); + +vi.mock('$app/stores', () => ({ + page: pageStore +})); + +import MobileTabBar from './MobileTabBar.svelte'; + +describe('MobileTabBar active state per route', () => { + it.each([ + ['/planner', 'Planer'], + ['/recipes', 'Rezepte'], + ['/shopping', 'Einkauf'], + ['/settings', 'Einstellungen'] + ])('on %s, %s is active and others are not', (route, expectedActiveLabel) => { + pageStore.set({ url: new URL(`http://localhost${route}`) }); + const { unmount } = render(MobileTabBar); + + const activeLink = screen.getByRole('link', { name: new RegExp(expectedActiveLabel) }); + expect(activeLink).toHaveAttribute('aria-current', 'page'); + + const allLinks = screen.getAllByRole('link'); + const inactiveLinks = allLinks.filter((link) => !link.textContent?.includes(expectedActiveLabel)); + for (const link of inactiveLinks) { + expect(link).not.toHaveAttribute('aria-current'); + } + + unmount(); + }); +}); diff --git a/frontend/src/lib/nav/MobileTabBar.svelte b/frontend/src/lib/nav/MobileTabBar.svelte new file mode 100644 index 0000000..d0b336a --- /dev/null +++ b/frontend/src/lib/nav/MobileTabBar.svelte @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/lib/nav/MobileTabBar.test.ts b/frontend/src/lib/nav/MobileTabBar.test.ts new file mode 100644 index 0000000..3e7f023 --- /dev/null +++ b/frontend/src/lib/nav/MobileTabBar.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import MobileTabBar from './MobileTabBar.svelte'; + +vi.mock('$app/stores', async () => { + const { readable } = await import('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('renders icons for each nav item', () => { + render(MobileTabBar); + expect(screen.getByText('📅')).toBeInTheDocument(); + expect(screen.getByText('📖')).toBeInTheDocument(); + expect(screen.getByText('🛒')).toBeInTheDocument(); + expect(screen.getByText('⚙️')).toBeInTheDocument(); + }); + + 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/src/lib/nav/TabletNavBar.svelte b/frontend/src/lib/nav/TabletNavBar.svelte new file mode 100644 index 0000000..85f119a --- /dev/null +++ b/frontend/src/lib/nav/TabletNavBar.svelte @@ -0,0 +1,23 @@ + + + diff --git a/frontend/src/lib/nav/TabletNavBar.test.ts b/frontend/src/lib/nav/TabletNavBar.test.ts new file mode 100644 index 0000000..2b32f43 --- /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', async () => { + const { readable } = await import('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(); + }); +}); diff --git a/frontend/src/lib/nav/nav.test.ts b/frontend/src/lib/nav/nav.test.ts new file mode 100644 index 0000000..0653e82 --- /dev/null +++ b/frontend/src/lib/nav/nav.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { mobileNavItems, desktopNavSections, isActiveRoute } 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']); + }); + }); + + describe('isActiveRoute', () => { + it('matches exact route', () => { + expect(isActiveRoute('/planner', '/planner')).toBe(true); + }); + + it('matches sub-route', () => { + expect(isActiveRoute('/planner', '/planner/week')).toBe(true); + }); + + it('does not match route with similar prefix', () => { + expect(isActiveRoute('/settings', '/settings-advanced')).toBe(false); + }); + + it('does not match unrelated route', () => { + expect(isActiveRoute('/planner', '/recipes')).toBe(false); + }); + }); +}); diff --git a/frontend/src/lib/nav/nav.ts b/frontend/src/lib/nav/nav.ts new file mode 100644 index 0000000..883bdcb --- /dev/null +++ b/frontend/src/lib/nav/nav.ts @@ -0,0 +1,39 @@ +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: '📅' }, + { href: '/recipes', label: 'Rezepte', icon: '📖' }, + { href: '/shopping', label: 'Einkauf', icon: '🛒' }, + { href: '/settings', label: 'Einstellungen', icon: '⚙️' } +]; + +export function isActiveRoute(href: string, pathname: string): boolean { + return pathname === href || pathname.startsWith(href + '/'); +} + +export const desktopNavSections: NavSection[] = [ + { + title: 'Plan', + items: [ + { href: '/planner', label: 'Planer', icon: '📅' }, + { href: '/recipes', label: 'Rezepte', icon: '📖' }, + { href: '/shopping', label: 'Einkauf', icon: '🛒' } + ] + }, + { + title: 'Haushalt', + items: [ + { href: '/members', label: 'Mitglieder', icon: '👥' }, + { href: '/settings', label: 'Einstellungen', icon: '⚙️' } + ] + } +]; 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..d90949a --- /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...

diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..5b032c7 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +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'] + }, + // Required for vitest: resolves Svelte to client entry (not server). + // SvelteKit's plugin overrides this for SSR builds — verified safe. + resolve: { + conditions: ['browser'] + } +});