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 <TimelineView> under the layout's <main>. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -215,6 +215,7 @@ export default defineConfig(
|
||||
'ocr',
|
||||
'activity',
|
||||
'conversation',
|
||||
'timeline',
|
||||
'shared'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -78,6 +78,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
>
|
||||
{m.nav_geschichten()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/zeitstrahl"
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/zeitstrahl')
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_zeitstrahl()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
@@ -190,6 +200,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
{m.nav_geschichten()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/zeitstrahl"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/zeitstrahl')
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_zeitstrahl()}
|
||||
</a>
|
||||
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
|
||||
19
frontend/src/routes/zeitstrahl/+page.server.ts
Normal file
19
frontend/src/routes/zeitstrahl/+page.server.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, extractErrorCode } from '$lib/shared/api.server';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
|
||||
// Global timeline: personId is undefined, so no query params (REQ-001). SSR-first
|
||||
// via createApiClient so the session cookie is forwarded; no client-side fetch
|
||||
// (REQ-002). The raw payload (correspondent names/titles) is PII — never logged.
|
||||
export async function load({ fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
const result = await api.GET('/api/timeline');
|
||||
|
||||
if (result.response.status === 401) throw redirect(302, '/login');
|
||||
|
||||
if (!result.response.ok) {
|
||||
throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
|
||||
}
|
||||
|
||||
return { timeline: result.data! };
|
||||
}
|
||||
16
frontend/src/routes/zeitstrahl/+page.svelte
Normal file
16
frontend/src/routes/zeitstrahl/+page.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
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();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.timeline_heading()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-5xl px-4 py-8">
|
||||
<h1 class="mb-8 font-serif text-2xl font-bold text-brand-navy">{m.timeline_heading()}</h1>
|
||||
<TimelineView timeline={data.timeline} />
|
||||
</div>
|
||||
72
frontend/src/routes/zeitstrahl/page.server.test.ts
Normal file
72
frontend/src/routes/zeitstrahl/page.server.test.ts
Normal file
@@ -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<typeof load>[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') }
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user