From 6f3229925500f1d5be00e7828edd75735d5a25d7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 13 Jun 2026 19:49:15 +0200 Subject: [PATCH] feat(timeline): add /zeitstrahl route, SSR load, and nav link SSR-first load fetches GET /api/timeline via createApiClient (auth cookie forwarded), no query params for the global view (REQ-001), returns { timeline } with no client-side fetch (REQ-002); 401 -> /login, any other non-ok -> error(status, getErrorMessage(...)), never raw JSON, no PII logged (REQ-022). The page renders under the layout's
. Adds the Zeitstrahl nav link (desktop + mobile) and 'timeline' to the eslint routes boundary allow-list so the route may import the domain. Refs #779 Co-Authored-By: Claude Opus 4.8 --- frontend/eslint.config.js | 1 + frontend/src/routes/AppNav.svelte | 20 ++++++ .../src/routes/zeitstrahl/+page.server.ts | 19 +++++ frontend/src/routes/zeitstrahl/+page.svelte | 16 +++++ .../src/routes/zeitstrahl/page.server.test.ts | 72 +++++++++++++++++++ 5 files changed, 128 insertions(+) create mode 100644 frontend/src/routes/zeitstrahl/+page.server.ts create mode 100644 frontend/src/routes/zeitstrahl/+page.svelte create mode 100644 frontend/src/routes/zeitstrahl/page.server.test.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 40aee11b..b7d3975b 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -215,6 +215,7 @@ export default defineConfig( 'ocr', 'activity', 'conversation', + 'timeline', 'shared' ] } diff --git a/frontend/src/routes/AppNav.svelte b/frontend/src/routes/AppNav.svelte index 260c96c8..e70340b5 100644 --- a/frontend/src/routes/AppNav.svelte +++ b/frontend/src/routes/AppNav.svelte @@ -78,6 +78,16 @@ function handleOverlayKeydown(event: KeyboardEvent) { > {m.nav_geschichten()} + + + {m.nav_zeitstrahl()} + {#if isAdmin} + + {m.nav_zeitstrahl()} + + {#if isAdmin} +import * as m from '$lib/paraglide/messages.js'; +import TimelineView from '$lib/timeline/TimelineView.svelte'; +import type { PageData } from './$types'; + +let { data }: { data: PageData } = $props(); + + + + {m.timeline_heading()} + + +
+

{m.timeline_heading()}

+ +
diff --git a/frontend/src/routes/zeitstrahl/page.server.test.ts b/frontend/src/routes/zeitstrahl/page.server.test.ts new file mode 100644 index 00000000..e7613eec --- /dev/null +++ b/frontend/src/routes/zeitstrahl/page.server.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(), + extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code +})); + +import { load } from './+page.server'; +import { createApiClient } from '$lib/shared/api.server'; +import { getErrorMessage } from '$lib/shared/errors'; + +beforeEach(() => vi.clearAllMocks()); + +const TIMELINE = { years: [{ year: 1914, entries: [] }], undated: [] }; + +function mockApi(opts: { ok?: boolean; status?: number; data?: unknown; error?: unknown }) { + const { ok = true, status = 200, data = TIMELINE, error } = opts; + const GET = vi.fn().mockResolvedValue({ + response: { ok, status }, + data: ok ? data : undefined, + error + }); + vi.mocked(createApiClient).mockReturnValue({ GET } as unknown as ReturnType< + typeof createApiClient + >); + return GET; +} + +function callLoad() { + return load({ + fetch: vi.fn() as unknown as typeof fetch, + url: new URL('http://localhost/zeitstrahl'), + request: new Request('http://localhost/zeitstrahl'), + route: { id: '/zeitstrahl' }, + params: {} + } as unknown as Parameters[0]); +} + +describe('zeitstrahl +page.server load', () => { + it('fetches GET /api/timeline and returns { timeline } on ok (REQ-001/002)', async () => { + const GET = mockApi({ data: TIMELINE }); + const result = await callLoad(); + expect(GET).toHaveBeenCalledWith('/api/timeline'); + expect(result).toEqual({ timeline: TIMELINE }); + }); + + it('redirects to /login on 401 (REQ-022)', async () => { + mockApi({ ok: false, status: 401 }); + await expect(callLoad()).rejects.toMatchObject({ status: 302, location: '/login' }); + }); + + it('throws a mapped error on 404 (REQ-022)', async () => { + mockApi({ ok: false, status: 404, error: { code: 'TIMELINE_EVENT_NOT_FOUND' } }); + await expect(callLoad()).rejects.toMatchObject({ + status: 404, + body: { message: getErrorMessage('TIMELINE_EVENT_NOT_FOUND') } + }); + }); + + it('throws a mapped error on 500 (REQ-022)', async () => { + mockApi({ ok: false, status: 500, error: undefined }); + await expect(callLoad()).rejects.toMatchObject({ status: 500 }); + }); + + it('throws a mapped FORBIDDEN error on 403 (REQ-022)', async () => { + mockApi({ ok: false, status: 403, error: { code: 'FORBIDDEN' } }); + await expect(callLoad()).rejects.toMatchObject({ + status: 403, + body: { message: getErrorMessage('FORBIDDEN') } + }); + }); +});