diff --git a/.specify/rtm.md b/.specify/rtm.md
index a8a79d6c..12b8a524 100644
--- a/.specify/rtm.md
+++ b/.specify/rtm.md
@@ -79,3 +79,30 @@
| REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done |
| REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done |
| REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done |
+| REQ-001 | `/zeitstrahl` renders the global timeline for authenticated users, personId undefined | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts`, `+page.svelte` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
+| REQ-002 | server-load fetches GET /api/timeline via createApiClient, returns { timeline }, no client fetch | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#fetches GET /api/timeline and returns { timeline } on ok` | Done |
+| REQ-003 | render bands + entries in DTO order, no client re-sort/re-bucket | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `TimelineView.svelte` | `YearBand.svelte.spec.ts#renders entries in DTO order`, `TimelineView.svelte.spec.ts#renders the timeline as a single
` | Done |
+| REQ-004 | ≥1024px centered axis, letters alternating left/right | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` (data-side CSS), `TimelineView.svelte` | `TimelineView.svelte.spec.ts#places consecutive letter cards on alternating sides`, `e2e/zeitstrahl.spec.ts` | Done |
+| REQ-005 | <1024px single left axis, no overflow down to 320px | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte`, `LetterCard.svelte` | `e2e/zeitstrahl.spec.ts#no horizontal overflow at 320px with long correspondent names` | Done |
+| REQ-006 | single `` chronological; each band a `` with sticky `` at top:4rem | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `YearBand.svelte` | `TimelineView.svelte.spec.ts#renders the timeline as a single `, `YearBand.svelte.spec.ts#sticky h2 at top:4rem` | Done |
+| REQ-007 | derived entry → centered family pill with glyph + German derivedType label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte`, `eventCardConfig.ts` | `EventPill.svelte.spec.ts#derived marriage/birth/death`, `eventCardConfig.spec.ts` | Done |
+| REQ-008 | curated PERSONAL pill; edit affordance only when eventId != null | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/EventPill.svelte` | `EventPill.svelte.spec.ts#edit affordance for curated with eventId`, `#no edit affordance when eventId is null`, `#no edit affordance for a derived event` | Done |
+| REQ-009 | HISTORICAL → full-width band once in eventDate year; RANGE span pill with Zeitraum aria-label | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#RANGE span pill 1914–1918 with a Zeitraum aria-label` | Done |
+| REQ-010 | RANGE with null eventDateEnd → start-year label, no span pill, no crash | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` | `WorldBand.svelte.spec.ts#degrades a RANGE with no end to the start year` | Done |
+| REQ-011 | band ≤12 letters → individual LetterCards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearBand.svelte` | `YearBand.svelte.spec.ts#renders each letter as a card`, `TimelineView.svelte.spec.ts` | Done |
+| REQ-012 | band >12 letters → single YearLetterStrip with count + 12-month sparkline + expand toggle | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/YearLetterStrip.svelte`, `timelineDensity.ts` | `YearLetterStrip.svelte.spec.ts`, `YearBand.svelte.spec.ts#renders a single strip`, `timelineDensity.spec.ts#isDense` | Done |
+| REQ-013 | every dated entry renders date via timelineDateLabel; null → no chip | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#renders the precision date exactly`, `#renders no date chip when timelineDateLabel returns null` | Done |
+| REQ-014 | empty senderName/receiverName → "Unbekannt" placeholder, never a bare arrow | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#shows "Unbekannt" for an empty sender`, `#empty receiver` | Done |
+| REQ-015 | interior empty-year run → one folded GapSpan (single year if length 1) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte`, `GapSpan.svelte` | `TimelineView.svelte.spec.ts#folds an interior run of empty years`, `#single empty interior year`, `GapSpan.svelte.spec.ts` | Done |
+| REQ-016 | undated non-empty → final "Ohne Datum" section; empty → absent from DOM | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders an "Ohne Datum" section`, `#omits the "Ohne Datum" section when empty` | Done |
+| REQ-017 | years + undated both empty → timeline.empty_state message | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#shows the empty state and no ol` | Done |
+| REQ-018 | each layer carries a non-color redundant cue (glyph aria-hidden + sr-only label) | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/eventCardConfig.ts`, `EventPill.svelte`, `WorldBand.svelte` | `TimelineView.svelte.spec.ts#redundant non-color cue label`, `EventPill.svelte.spec.ts#wraps the glyph aria-hidden`, `WorldBand.svelte.spec.ts#world glyph` | Done |
+| REQ-019 | every accent meets WCAG AA in light + dark; HISTORICAL label falls back to text-ink-2 | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/WorldBand.svelte` (text-ink-2) | manual pre-merge contrast check (both ratios recorded in PR) | Done |
+| REQ-020 | LetterCard link ≥44px touch target + visible focus-visible ring | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#has a touch target of at least 44px` | Done |
+| REQ-021 | OCR/import text rendered via `{...}` escaping; no `{@html}` in lib/timeline/ | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/*` | code review + `grep -r '@html' frontend/src/lib/timeline/` → zero; `LetterCard.svelte.spec.ts` | Done |
+| REQ-022 | non-ok load → error(status, mapped); 401 → redirect('/login'); never raw JSON | #779 | zeitstrahl-global-view | `frontend/src/routes/zeitstrahl/+page.server.ts` | `zeitstrahl/page.server.test.ts#redirects to /login on 401`, `#404`, `#500`, `#403` | Done |
+| REQ-023 | LetterCard href is exactly /documents/{documentId}, no target | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/LetterCard.svelte` | `LetterCard.svelte.spec.ts#links to exactly /documents/{documentId} with no target` | Done |
+| REQ-024 | all user-facing strings via Paraglide keys (layer/derived labels localized per locale) | #779 | zeitstrahl-global-view | `frontend/messages/{de,en,es}.json` | `messages.spec.ts#timeline layer/derived labels are localized per locale`, Paraglide compile | Done |
+| REQ-025 | personId prop declared but undefined in global view; not passed to leaf cards | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/TimelineView.svelte` | `TimelineView.svelte.spec.ts#renders all years and undated entries with personId undefined` | Done |
+| REQ-026 | month-bucket helpers in $lib/shared/utils/monthBuckets.ts; no lib/timeline → lib/document import | #779 | zeitstrahl-global-view | `frontend/src/lib/shared/utils/monthBuckets.ts` | `monthBuckets.spec.ts` (relocated) + eslint boundary + `grep lib/document` → zero | Done |
+| REQ-027 | monthHistogram returns 12 MonthBuckets for the band year via shared fillDensityGaps | #779 | zeitstrahl-global-view | `frontend/src/lib/timeline/timelineDensity.ts` | `timelineDensity.spec.ts#monthHistogram` | Done |
diff --git a/CLAUDE.md b/CLAUDE.md
index 8fbfe119..19998f96 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -207,6 +207,7 @@ frontend/src/routes/
├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
+├── zeitstrahl/ Global timeline (Zeitstrahl) — life-events + events + letters woven in time; SSR-loads GET /api/timeline, renders lib/timeline/TimelineView (Datum mode)
├── themen/ Topics directory — browsable tag index
├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management
diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md
index 29cc18e1..bc6dabac 100644
--- a/docs/GLOSSARY.md
+++ b/docs/GLOSSARY.md
@@ -170,6 +170,8 @@ _Not to be confused with a document item's optional note_ — a document item's
**Zeitstrahl** `[user-facing]` — the family timeline view, rendering curated `TimelineEvent`s and derived life-events chronologically. The milestone home of the `timeline` domain.
+**Lebensweg** `[user-facing]` — the per-person variant of the *Zeitstrahl*: the same `TimelineView` component, scoped to a single person via a `personId` prop, rendering that person's life-events, events, and letters as a left-anchored rail. The global Zeitstrahl is the `personId`-undefined case of the same component (issue #10 wires the per-person rail; the prop seam ships with the global view).
+
**derived event** — a timeline entry computed on-read from curated `Person` or `PersonRelationship` data, never persisted. Carried as a `TimelineEntryDTO` with `derived=true` and a non-null `DerivedEventType`. Three subtypes: Geburt (birth, from `Person.birthDate`), Tod (death, from `Person.deathDate`), Heirat (marriage, from a `SPOUSE_OF` `PersonRelationship` edge). Callers of `assembleDerivedEvents()` are responsible for enforcing `READ_ALL` authorization before invoking it (ADR-043).
_Not to be confused with a `TimelineEvent`_ — a `TimelineEvent` is a curated record authored by a human and stored in `timeline_events`; a derived event is computed on-the-fly and never written to the database.
diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml
index cf424d63..6ba983ff 100644
--- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml
+++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml
@@ -14,6 +14,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") {
Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story/Journey list and detail pages. List: GeschichteListRow with REISE badge for JOURNEY type. Detail: dispatches to StoryReader (rich text + persons) or JourneyReader (intro + ordered JourneyItemCard/JourneyInterlude items + empty state) based on GeschichteType. BLOG_WRITE users see edit/delete actions. Loader: GET /api/geschichten, GET /api/geschichten/{id}.")
Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor and creation flow. New: TypeSelector (STORY/JOURNEY radio group with roving tabindex) → StoryCreate (TipTap rich text, person linking, POST /api/geschichten) or JourneyCreate (title + first item). Edit: branches on GeschichteType — STORY opens GeschichteEditor (TipTap body + GeschichteSidebar incl. StoryDocumentPanel: document linking via POST/DELETE /items); JOURNEY opens JourneyEditor (title, intro textarea, ordered JourneyItemRow list with drag-reorder + move-up/down, JourneyAddBar for document/interlude addition, GeschichteSidebar). JourneyEditor mutations: POST/DELETE /items, PUT /items/reorder, PATCH /items/{id}. Requires BLOG_WRITE permission.")
Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.")
+ Component(zeitstrahl, "/zeitstrahl", "SvelteKit Route", "Global timeline (Zeitstrahl). SSR loader: GET /api/timeline -> TimelineDTO. Renders lib/timeline/TimelineView (Datum mode): year bands (YearBand) with EventPill / WorldBand / LetterCard, dense-year YearLetterStrip (shared Sparkline + monthHistogram), folded GapSpan for empty-year runs, and an undated bucket. personId prop is the per-person Lebensweg seam (issue #10), undefined here.")
Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.")
Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.")
Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.")
@@ -27,6 +28,8 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications"
Rel(geschichten, backend, "GET /api/geschichten, GET /api/geschichten/{id}, DELETE /api/geschichten/{id}", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET /api/persons/{id} (pre-populate), POST /api/geschichten, PUT /api/geschichten/{id}, POST/DELETE /api/geschichten/{id}/items, PUT /api/geschichten/{id}/items/reorder, PATCH /api/geschichten/{id}/items/{itemId}", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON")
+Rel(user, zeitstrahl, "Reads the family timeline", "HTTPS / Browser")
+Rel(zeitstrahl, backend, "GET /api/timeline -> TimelineDTO", "HTTP / JSON")
Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON")
Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index a6fa8df7..3d367b4d 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -34,6 +34,7 @@ src/
│ ├── api/ # Internal API proxies (server-side only)
│ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
│ ├── stammbaum/ # Family tree
+│ ├── zeitstrahl/ # Global timeline (Zeitstrahl) — SSR loads /api/timeline, renders lib/timeline
│ ├── enrich/ # Enrichment workflow ([id], done)
│ ├── hilfe/transkription/ # Transcription help page
│ ├── profile/ # User profile settings
@@ -49,6 +50,7 @@ src/
│ │ ├── relationship/ # Relationship form + chip components
│ │ └── genealogy/ # Stammbaum (family tree) components
│ ├── tag/ # Tag domain: TagInput, TagChipList, TagParentPicker
+│ ├── timeline/ # Timeline (Zeitstrahl) domain: TimelineView, YearBand, EventPill, WorldBand, LetterCard, YearLetterStrip, GapSpan; dateLabel + timelineDensity + eventCardConfig (imports $lib/shared only, never document/)
│ ├── geschichte/ # Geschichte (story) domain: editor + card
│ ├── notification/ # Notification bell + dropdown + store
│ ├── activity/ # Activity feed (Chronik) components
@@ -59,8 +61,8 @@ src/
│ │ ├── hooks/ # Reusable Svelte state hooks (useTypeahead, etc.)
│ │ ├── server/ # Server-only utilities (locale, session)
│ │ ├── services/ # Client-side service helpers
-│ │ ├── utils/ # Pure utility functions (date, search, etc.)
-│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, etc.)
+│ │ ├── utils/ # Pure utility functions (date, search, monthBuckets — month-bucket math shared by document chart + timeline strip)
+│ │ ├── primitives/ # Generic UI primitives (BackButton, ProgressRing, Sparkline, etc.)
│ │ ├── dashboard/ # Dashboard stat components
│ │ ├── discussion/ # CommentThread + shared discussion UI
│ │ ├── help/ # Help/FAQ page components
diff --git a/frontend/e2e/zeitstrahl.spec.ts b/frontend/e2e/zeitstrahl.spec.ts
new file mode 100644
index 00000000..18c633e0
--- /dev/null
+++ b/frontend/e2e/zeitstrahl.spec.ts
@@ -0,0 +1,84 @@
+import { test, expect, type APIRequestContext } from '@playwright/test';
+
+/**
+ * Global /zeitstrahl timeline (#779). Runs against the real stack with the
+ * seeded admin session (auth.setup). Covers the primary journey (nav → page,
+ * timeline inside ) and the 320px no-overflow guarantee on a populated
+ * timeline seeded with 25+char correspondent names (REQ-005).
+ */
+
+const stamp = () => new Date().toISOString().replace(/[^0-9]/g, '');
+
+async function createPerson(request: APIRequestContext, firstName: string, lastName: string) {
+ const res = await request.post('/api/persons', {
+ data: { personType: 'PERSON', firstName, lastName }
+ });
+ if (!res.ok()) throw new Error(`create person failed: ${res.status()}`);
+ return (await res.json()).id as string;
+}
+
+/** Seeds one dated letter with long sender/receiver names so it lands on the timeline. */
+async function seedDatedLetter(request: APIRequestContext) {
+ const senderId = await createPerson(
+ request,
+ 'Friedrich-Wilhelm',
+ `Maximilian von Habsburg ${stamp()}`
+ );
+ const receiverId = await createPerson(
+ request,
+ 'Maria-Magdalena',
+ `Hohenzollern-Sigmaringen ${stamp()}`
+ );
+
+ const createRes = await request.post('/api/documents', {
+ multipart: { title: `E2E Zeitstrahl Brief ${stamp()}` }
+ });
+ if (!createRes.ok()) throw new Error(`create document failed: ${createRes.status()}`);
+ const docId = (await createRes.json()).id as string;
+
+ const put = await request.put(`/api/documents/${docId}`, {
+ multipart: {
+ title: `E2E Zeitstrahl Brief ${stamp()}`,
+ documentDate: '1915-06-15',
+ metaDatePrecision: 'DAY',
+ senderId,
+ receiverIds: receiverId
+ }
+ });
+ if (!put.ok()) throw new Error(`update document failed: ${put.status()}`);
+}
+
+test.describe('Zeitstrahl — global timeline (#779)', () => {
+ test('nav link opens /zeitstrahl and the timeline lives in ', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('navigation').getByRole('link', { name: 'Zeitstrahl' }).first().click();
+ await expect(page).toHaveURL(/\/zeitstrahl$/);
+ await expect(page.getByRole('heading', { level: 1, name: 'Zeitstrahl' })).toBeVisible();
+
+ // The main landmark contains either the populated or the empty state.
+ const main = page.getByRole('main');
+ const ol = main.locator('ol');
+ const empty = main.getByText('Noch keine Ereignisse.');
+ await expect(async () => {
+ const populated = (await ol.count()) > 0;
+ const isEmpty = await empty.isVisible().catch(() => false);
+ expect(populated || isEmpty).toBe(true);
+ }).toPass();
+ });
+
+ test('no horizontal overflow at 320px with long correspondent names (REQ-005)', async ({
+ page,
+ request
+ }) => {
+ await seedDatedLetter(request);
+
+ await page.setViewportSize({ width: 320, height: 900 });
+ await page.goto('/zeitstrahl');
+
+ // Populated: the seeded letter puts the timeline in the DOM.
+ await expect(page.getByRole('main').locator('ol')).toHaveCount(1);
+
+ const scrollWidth = await page.evaluate(() => document.body.scrollWidth);
+ expect(scrollWidth).toBe(320);
+ });
+});
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/messages/de.json b/frontend/messages/de.json
index c306a1a3..ec66eced 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "{count} werden bearbeitet",
"nav_stammbaum": "Stammbaum",
"nav_geschichten": "Geschichten",
+ "nav_zeitstrahl": "Zeitstrahl",
+ "timeline_heading": "Zeitstrahl",
+ "timeline_empty_state": "Noch keine Ereignisse.",
+ "timeline_undated_section": "Ohne Datum",
+ "timeline_unknown_person": "Unbekannt",
+ "timeline_gap_empty": "keine Einträge",
+ "timeline_letters_count": "{count} Briefe",
+ "timeline_strip_expand": "Briefe anzeigen",
+ "timeline_range_aria": "Zeitraum: {from} bis {to}",
+ "timeline_layer_world": "Weltgeschehen",
+ "timeline_layer_family": "Familie",
+ "timeline_derived_birth": "Geburt",
+ "timeline_derived_death": "Tod",
+ "timeline_derived_marriage": "Heirat",
"error_geschichte_not_found": "Die Geschichte wurde nicht gefunden.",
"error_journey_item_not_found": "Der Reise-Eintrag wurde nicht gefunden.",
"error_journey_item_position_conflict": "Die Reihenfolge wurde gerade von jemand anderem geändert – bitte laden Sie die Seite neu.",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 9f3bb8e0..234437ad 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "{count} will be edited",
"nav_stammbaum": "Family tree",
"nav_geschichten": "Stories",
+ "nav_zeitstrahl": "Timeline",
+ "timeline_heading": "Timeline",
+ "timeline_empty_state": "No events yet.",
+ "timeline_undated_section": "Without Date",
+ "timeline_unknown_person": "Unknown",
+ "timeline_gap_empty": "no entries",
+ "timeline_letters_count": "{count} letters",
+ "timeline_strip_expand": "Show letters",
+ "timeline_range_aria": "Period: {from} to {to}",
+ "timeline_layer_world": "World events",
+ "timeline_layer_family": "Family",
+ "timeline_derived_birth": "Birth",
+ "timeline_derived_death": "Death",
+ "timeline_derived_marriage": "Marriage",
"error_geschichte_not_found": "The story was not found.",
"error_journey_item_not_found": "The journey item was not found.",
"error_journey_item_position_conflict": "The order was just changed by someone else — please reload the page.",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index d4d8544d..aab54c4d 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -1032,6 +1032,20 @@
"bulk_edit_count_pill": "Se editarán {count}",
"nav_stammbaum": "Árbol genealógico",
"nav_geschichten": "Historias",
+ "nav_zeitstrahl": "Línea de tiempo",
+ "timeline_heading": "Línea de tiempo",
+ "timeline_empty_state": "Aún no hay eventos.",
+ "timeline_undated_section": "Sin Fecha",
+ "timeline_unknown_person": "Desconocido",
+ "timeline_gap_empty": "sin entradas",
+ "timeline_letters_count": "{count} cartas",
+ "timeline_strip_expand": "Mostrar cartas",
+ "timeline_range_aria": "Período: {from} a {to}",
+ "timeline_layer_world": "Acontecimientos mundiales",
+ "timeline_layer_family": "Familia",
+ "timeline_derived_birth": "Nacimiento",
+ "timeline_derived_death": "Fallecimiento",
+ "timeline_derived_marriage": "Matrimonio",
"error_geschichte_not_found": "No se encontró la historia.",
"error_journey_item_not_found": "No se encontró el elemento del viaje.",
"error_journey_item_position_conflict": "El orden fue cambiado por otra persona — por favor recargue la página.",
diff --git a/frontend/src/lib/document/README.md b/frontend/src/lib/document/README.md
index 16885de7..b0485d76 100644
--- a/frontend/src/lib/document/README.md
+++ b/frontend/src/lib/document/README.md
@@ -30,6 +30,7 @@ Sub-folders: `annotation/`, `transcription/`, `viewer/`.
- `tag/TagInput.svelte` — tag chip input
- `ocr/OcrProgress.svelte` — job status indicator in the document header
- `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI
+- `shared/utils/monthBuckets.ts` — the density chart's pure month-bucket math (boundaries, gap-fill, year aggregation, axis ticks) now lives in `shared/` so the `timeline/` domain can reuse it; `document/timeline.ts` keeps only the `/api/documents/density` glue (`fetchDensity`, `buildDensityUrl`)
## Backend counterpart
diff --git a/frontend/src/lib/document/TimelineBars.svelte b/frontend/src/lib/document/TimelineBars.svelte
index fb0db1ca..c5784028 100644
--- a/frontend/src/lib/document/TimelineBars.svelte
+++ b/frontend/src/lib/document/TimelineBars.svelte
@@ -1,6 +1,6 @@
+
+
+ {#each values as value, i (i)}
+
+ {/each}
+
diff --git a/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts b/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts
new file mode 100644
index 00000000..228302b0
--- /dev/null
+++ b/frontend/src/lib/shared/primitives/Sparkline.svelte.spec.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import Sparkline from './Sparkline.svelte';
+
+afterEach(() => cleanup());
+
+describe('Sparkline', () => {
+ it('renders one bar per value', () => {
+ render(Sparkline, { values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] });
+ const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
+ expect(bars).toHaveLength(12);
+ });
+
+ it('scales bar heights relative to the largest value', () => {
+ render(Sparkline, { values: [5, 10, 0] });
+ const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
+ const h = (i: number) => parseFloat(bars[i].style.height);
+ // 10 is the max → tallest; 5 is half of the max's height; 0 is the shortest.
+ expect(h(1)).toBeGreaterThan(h(0));
+ expect(h(0)).toBeGreaterThan(h(2));
+ });
+
+ it('exposes an accessible label when provided', () => {
+ render(Sparkline, { values: [1, 2, 3], label: 'Monatsdichte' });
+ const img = document.querySelector('[role="img"]');
+ expect(img?.getAttribute('aria-label')).toBe('Monatsdichte');
+ });
+});
diff --git a/frontend/src/lib/shared/utils/monthBuckets.spec.ts b/frontend/src/lib/shared/utils/monthBuckets.spec.ts
new file mode 100644
index 00000000..11f18e23
--- /dev/null
+++ b/frontend/src/lib/shared/utils/monthBuckets.spec.ts
@@ -0,0 +1,267 @@
+import { describe, it, expect } from 'vitest';
+import {
+ monthBoundaryFrom,
+ monthBoundaryTo,
+ buildMonthSequence,
+ fillDensityGaps,
+ aggregateToYears,
+ selectionBoundaryFrom,
+ selectionBoundaryTo,
+ clipBucketsToRange,
+ tickIndicesFor,
+ formatTickLabel
+} from './monthBuckets';
+
+describe('monthBoundaryFrom', () => {
+ it('returns the first day of the given month', () => {
+ expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
+ });
+
+ it('handles January', () => {
+ expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
+ });
+});
+
+describe('monthBoundaryTo', () => {
+ it('returns the last day of a 31-day month', () => {
+ expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
+ });
+
+ it('returns the last day of a 30-day month', () => {
+ expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
+ });
+
+ it('returns 28 for February in a non-leap year', () => {
+ expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
+ });
+
+ it('returns 29 for February in a leap year', () => {
+ expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
+ });
+});
+
+describe('buildMonthSequence', () => {
+ it('returns a single month when min and max are in the same month', () => {
+ expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
+ });
+
+ it('returns months from minDate through maxDate inclusive', () => {
+ expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
+ '1915-08',
+ '1915-09',
+ '1915-10',
+ '1915-11'
+ ]);
+ });
+
+ it('crosses year boundaries correctly', () => {
+ expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
+ '1915-11',
+ '1915-12',
+ '1916-01',
+ '1916-02'
+ ]);
+ });
+
+ it('returns empty array when minDate or maxDate is null', () => {
+ expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
+ expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
+ expect(buildMonthSequence(null, null)).toEqual([]);
+ });
+});
+
+describe('fillDensityGaps', () => {
+ it('returns empty array when minDate or maxDate is null', () => {
+ expect(fillDensityGaps([], null, null)).toEqual([]);
+ });
+
+ it('preserves existing buckets and adds zero-count buckets for missing months', () => {
+ const buckets = [
+ { month: '1915-08', count: 5 },
+ { month: '1915-11', count: 2 }
+ ];
+
+ const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
+
+ expect(result).toEqual([
+ { month: '1915-08', count: 5 },
+ { month: '1915-09', count: 0 },
+ { month: '1915-10', count: 0 },
+ { month: '1915-11', count: 2 }
+ ]);
+ });
+
+ it('returns all-zero sequence when buckets array is empty', () => {
+ const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
+
+ expect(result).toEqual([
+ { month: '1915-08', count: 0 },
+ { month: '1915-09', count: 0 },
+ { month: '1915-10', count: 0 }
+ ]);
+ });
+
+ it('keeps results sorted chronologically even when buckets arrive out of order', () => {
+ const buckets = [
+ { month: '1915-10', count: 3 },
+ { month: '1915-08', count: 1 }
+ ];
+
+ const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
+
+ expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
+ });
+});
+
+describe('aggregateToYears', () => {
+ it('returns empty array for empty input', () => {
+ expect(aggregateToYears([])).toEqual([]);
+ });
+
+ it('sums counts within the same year', () => {
+ const result = aggregateToYears([
+ { month: '1915-08', count: 5 },
+ { month: '1915-09', count: 2 },
+ { month: '1915-10', count: 8 }
+ ]);
+ expect(result).toEqual([{ month: '1915', count: 15 }]);
+ });
+
+ it('produces one bucket per distinct year, sorted chronologically', () => {
+ const result = aggregateToYears([
+ { month: '1916-01', count: 3 },
+ { month: '1915-08', count: 5 },
+ { month: '1916-04', count: 7 },
+ { month: '1914-12', count: 1 }
+ ]);
+ expect(result).toEqual([
+ { month: '1914', count: 1 },
+ { month: '1915', count: 5 },
+ { month: '1916', count: 10 }
+ ]);
+ });
+});
+
+describe('clipBucketsToRange', () => {
+ const buckets = [
+ { month: '1915-08', count: 5 },
+ { month: '1915-09', count: 2 },
+ { month: '1915-10', count: 8 },
+ { month: '1915-11', count: 3 }
+ ];
+
+ it('returns the original buckets when range bounds are null', () => {
+ expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
+ });
+
+ it('keeps only buckets whose month falls within the range', () => {
+ expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
+ { month: '1915-09', count: 2 },
+ { month: '1915-10', count: 8 }
+ ]);
+ });
+
+ it('returns an empty array when the range excludes everything', () => {
+ expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
+ });
+
+ it('treats partial dates correctly when bounds cross month boundaries', () => {
+ expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
+ { month: '1915-09', count: 2 },
+ { month: '1915-10', count: 8 }
+ ]);
+ });
+});
+
+describe('selectionBoundaryFrom / To', () => {
+ it('handles month labels (YYYY-MM)', () => {
+ expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
+ expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
+ });
+
+ it('handles year labels (YYYY)', () => {
+ expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
+ expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
+ });
+});
+
+describe('tickIndicesFor', () => {
+ it('returns no indices for an empty bucket list', () => {
+ expect(tickIndicesFor([])).toEqual([]);
+ });
+
+ it('picks years divisible by 25 when the year span exceeds 120', () => {
+ const buckets = Array.from({ length: 150 }, (_, i) => ({
+ month: String(1875 + i),
+ count: 1
+ }));
+ const ticks = tickIndicesFor(buckets);
+ const labels = ticks.map((i) => buckets[i].month);
+ expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
+ });
+
+ it('picks years divisible by 10 for medium ranges (~50 years)', () => {
+ const buckets = Array.from({ length: 50 }, (_, i) => ({
+ month: String(1900 + i),
+ count: 1
+ }));
+ const ticks = tickIndicesFor(buckets);
+ const labels = ticks.map((i) => buckets[i].month);
+ expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
+ });
+
+ it('picks January boundaries for long month ranges', () => {
+ const buckets = [
+ { month: '1914-08', count: 1 },
+ { month: '1914-09', count: 1 },
+ { month: '1914-10', count: 1 },
+ { month: '1914-11', count: 1 },
+ { month: '1914-12', count: 1 },
+ { month: '1915-01', count: 1 },
+ { month: '1915-02', count: 1 },
+ { month: '1915-03', count: 1 },
+ { month: '1915-04', count: 1 },
+ { month: '1915-05', count: 1 },
+ { month: '1915-06', count: 1 },
+ { month: '1915-07', count: 1 },
+ { month: '1915-08', count: 1 },
+ { month: '1915-09', count: 1 },
+ { month: '1915-10', count: 1 },
+ { month: '1915-11', count: 1 },
+ { month: '1915-12', count: 1 },
+ { month: '1916-01', count: 1 },
+ { month: '1916-02', count: 1 }
+ ];
+ const ticks = tickIndicesFor(buckets);
+ expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
+ });
+
+ it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
+ const buckets = Array.from({ length: 12 }, (_, i) => ({
+ month: `1905-${String(i + 1).padStart(2, '0')}`,
+ count: 1
+ }));
+ const ticks = tickIndicesFor(buckets);
+ expect(ticks.length).toBeGreaterThanOrEqual(5);
+ expect(ticks.length).toBeLessThanOrEqual(7);
+ expect(ticks[0]).toBe(0);
+ });
+});
+
+describe('formatTickLabel', () => {
+ it('returns the year string unchanged for year labels', () => {
+ expect(formatTickLabel('1905', 'en-US')).toBe('1905');
+ });
+
+ it('formats month labels with the year by default', () => {
+ const result = formatTickLabel('1905-06', 'en-US');
+ expect(result).toMatch(/Jun/);
+ expect(result).toMatch(/1905/);
+ });
+
+ it('omits the year when omitYear is true', () => {
+ const result = formatTickLabel('1905-06', 'en-US', true);
+ expect(result).toMatch(/Jun/);
+ expect(result).not.toMatch(/1905/);
+ });
+});
diff --git a/frontend/src/lib/shared/utils/monthBuckets.ts b/frontend/src/lib/shared/utils/monthBuckets.ts
new file mode 100644
index 00000000..46a28212
--- /dev/null
+++ b/frontend/src/lib/shared/utils/monthBuckets.ts
@@ -0,0 +1,163 @@
+import type { components } from '$lib/generated/api';
+
+/**
+ * Pure month-bucket math shared by the document density chart (`lib/document/`)
+ * and the global timeline strip (`lib/timeline/`). Reuses the generated
+ * `MonthBucket` schema type so both surfaces stay coupled to the backend shape.
+ * No I/O, no DOM — relocated here so `lib/timeline/` never imports `lib/document/`.
+ */
+export type MonthBucket = components['schemas']['MonthBucket'];
+
+export function monthBoundaryFrom(yearMonth: string): string {
+ return `${yearMonth}-01`;
+}
+
+export function monthBoundaryTo(yearMonth: string): string {
+ const [year, month] = yearMonth.split('-').map(Number);
+ // Day 0 of `month + 1` rolls back to the last day of `month` — so passing
+ // `month` (1-indexed) into `Date.UTC(year, month, 0)` lands on the last day
+ // of that month. Handles 28/29/30/31 and leap years without a lookup table.
+ const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
+ return `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
+}
+
+export function buildMonthSequence(minDate: string | null, maxDate: string | null): string[] {
+ if (!minDate || !maxDate) return [];
+
+ const [minY, minM] = minDate.split('-').map(Number);
+ const [maxY, maxM] = maxDate.split('-').map(Number);
+
+ const sequence: string[] = [];
+ let year = minY;
+ let month = minM;
+
+ while (year < maxY || (year === maxY && month <= maxM)) {
+ sequence.push(`${year}-${String(month).padStart(2, '0')}`);
+ month += 1;
+ if (month > 12) {
+ month = 1;
+ year += 1;
+ }
+ }
+
+ return sequence;
+}
+
+export function fillDensityGaps(
+ buckets: MonthBucket[],
+ minDate: string | null,
+ maxDate: string | null
+): MonthBucket[] {
+ const sequence = buildMonthSequence(minDate, maxDate);
+ if (sequence.length === 0) return [];
+
+ const counts = new Map(buckets.map((b) => [b.month, b.count]));
+ return sequence.map((month) => ({ month, count: counts.get(month) ?? 0 }));
+}
+
+/**
+ * Returns only the month buckets whose YYYY-MM falls inside the provided
+ * `[fromInclusive, toInclusive]` ISO date range. When either bound is null the
+ * input array is returned unchanged. Used by the timeline's zoom-in tool to
+ * narrow the visible bars without refetching data.
+ *
+ * @internal Sole call site is `TimelineDensityFilter.svelte`. Exported so the
+ * unit suite (`monthBuckets.spec.ts`) can pin the boundary semantics directly.
+ */
+export function clipBucketsToRange(
+ buckets: MonthBucket[],
+ fromInclusive: string | null,
+ toInclusive: string | null
+): MonthBucket[] {
+ if (!fromInclusive || !toInclusive) return buckets;
+ const fromMonth = fromInclusive.slice(0, 7);
+ const toMonth = toInclusive.slice(0, 7);
+ return buckets.filter((b) => b.month >= fromMonth && b.month <= toMonth);
+}
+
+/**
+ * Aggregates month-granular buckets into one entry per year. Month strings are
+ * truncated to "YYYY" and counts are summed. Used when the date span is too
+ * long for month-granular bars to render at a clickable size.
+ */
+export function aggregateToYears(buckets: MonthBucket[]): MonthBucket[] {
+ const totals = new Map();
+ for (const b of buckets) {
+ const year = b.month.slice(0, 4);
+ totals.set(year, (totals.get(year) ?? 0) + b.count);
+ }
+ return Array.from(totals.entries())
+ .map(([year, count]) => ({ month: year, count }))
+ .sort((a, b) => a.month.localeCompare(b.month));
+}
+
+/**
+ * Boundary helpers for selection. Accept either "YYYY-MM" (month) or "YYYY"
+ * (year) and return the matching LocalDate string.
+ */
+export function selectionBoundaryFrom(label: string): string {
+ return label.length === 4 ? `${label}-01-01` : `${label}-01`;
+}
+
+export function selectionBoundaryTo(label: string): string {
+ if (label.length === 4) return `${label}-12-31`;
+ return monthBoundaryTo(label);
+}
+
+/**
+ * Picks bucket indices that should get an X-axis tick label. The strategy adapts
+ * to whether bars are years or months and how many are visible:
+ * - Year bars: pick years divisible by a step that scales with range length
+ * (every 25 yrs for >120 bars, every 20 / 10 / 5 / 1 below).
+ * - Month bars: prefer January boundaries (year breaks). For ≤18 bars (e.g.
+ * one year zoomed in to months), fall back to evenly spaced ticks so we
+ * show ~6 labels even when no January boundary exists.
+ */
+export function tickIndicesFor(filled: MonthBucket[]): number[] {
+ if (filled.length === 0) return [];
+ const isYearMode = filled[0].month.length === 4;
+ const indices: number[] = [];
+
+ if (isYearMode) {
+ const years = filled.length;
+ const step =
+ years > 120 ? 25 : years > 60 ? 20 : years > 30 ? 10 : years > 12 ? 5 : years > 6 ? 2 : 1;
+ for (let i = 0; i < filled.length; i++) {
+ const year = parseInt(filled[i].month, 10);
+ if (year % step === 0) indices.push(i);
+ }
+ return indices;
+ }
+
+ if (filled.length <= 18) {
+ const step = Math.max(1, Math.round(filled.length / 6));
+ for (let i = 0; i < filled.length; i += step) indices.push(i);
+ return indices;
+ }
+
+ // Long month range — pick January boundaries (year breaks).
+ for (let i = 0; i < filled.length; i++) {
+ if (filled[i].month.endsWith('-01')) indices.push(i);
+ }
+ // Fallback if there's no January in the visible range (rare): even spacing.
+ if (indices.length === 0) {
+ const step = Math.max(1, Math.round(filled.length / 6));
+ for (let i = 0; i < filled.length; i += step) indices.push(i);
+ }
+ return indices;
+}
+
+/**
+ * Formats a bucket month label ("YYYY" or "YYYY-MM") for the X-axis. When
+ * `omitYear` is true the year is dropped so a 12-month zoomed view reads as
+ * "Jan", "Feb", … without repetition.
+ */
+export function formatTickLabel(label: string, locale?: string, omitYear = false): string {
+ if (label.length === 4) return label;
+ const [yearStr, monthStr] = label.split('-');
+ const date = new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, 1);
+ const opts: Intl.DateTimeFormatOptions = omitYear
+ ? { month: 'short' }
+ : { month: 'short', year: 'numeric' };
+ return new Intl.DateTimeFormat(locale, opts).format(date);
+}
diff --git a/frontend/src/lib/timeline/EventPill.svelte b/frontend/src/lib/timeline/EventPill.svelte
new file mode 100644
index 00000000..9b125cb7
--- /dev/null
+++ b/frontend/src/lib/timeline/EventPill.svelte
@@ -0,0 +1,59 @@
+
+
+
+
+
+ {config.glyph}
+ {config.label}
+
+
+ {#if entry.title}
+ {entry.title}
+ {/if}
+ {#if dateLabel}
+ {dateLabel}
+ {/if}
+
+ {#if canEdit}
+
+ ✎
+ {m.btn_edit()}
+
+ {/if}
+
+
diff --git a/frontend/src/lib/timeline/EventPill.svelte.spec.ts b/frontend/src/lib/timeline/EventPill.svelte.spec.ts
new file mode 100644
index 00000000..945ea65f
--- /dev/null
+++ b/frontend/src/lib/timeline/EventPill.svelte.spec.ts
@@ -0,0 +1,90 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import EventPill from './EventPill.svelte';
+import { makeEntry } from './test-factories';
+
+afterEach(() => cleanup());
+
+const EVENT_ID = '33333333-3333-3333-3333-333333333333';
+
+function derived(derivedType: 'BIRTH' | 'DEATH' | 'MARRIAGE', title: string) {
+ return makeEntry({
+ kind: 'EVENT',
+ derived: true,
+ derivedType,
+ title,
+ senderName: '',
+ receiverName: '',
+ precision: 'YEAR',
+ eventDate: '1914-01-01',
+ documentId: undefined
+ });
+}
+
+describe('EventPill', () => {
+ it('renders a derived marriage as ⚭ + "Heirat" + title (REQ-007)', () => {
+ render(EventPill, { entry: derived('MARRIAGE', 'Heirat: Karl & Elfriede') });
+ expect(document.body.textContent).toContain('⚭');
+ expect(document.body.textContent).toContain('Heirat');
+ expect(document.body.textContent).toContain('Heirat: Karl & Elfriede');
+ });
+
+ it('renders a derived birth as * + "Geburt" (REQ-007)', () => {
+ render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
+ expect(document.body.textContent).toContain('*');
+ expect(document.body.textContent).toContain('Geburt');
+ });
+
+ it('renders a derived death as † + "Tod" (REQ-007)', () => {
+ render(EventPill, { entry: derived('DEATH', 'Tod: Karl') });
+ expect(document.body.textContent).toContain('†');
+ expect(document.body.textContent).toContain('Tod');
+ });
+
+ it('wraps the glyph aria-hidden with an sr-only label sibling (REQ-018)', () => {
+ render(EventPill, { entry: derived('BIRTH', 'Geburt: Hans') });
+ const hidden = document.querySelector('[aria-hidden="true"]');
+ expect(hidden?.textContent).toBe('*');
+ const srOnly = document.querySelector('.sr-only');
+ expect(srOnly?.textContent).toBe('Geburt');
+ });
+
+ it('shows an edit affordance for a curated PERSONAL event with an eventId (REQ-008)', () => {
+ render(EventPill, {
+ entry: makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'PERSONAL',
+ eventId: EVENT_ID,
+ title: 'Auswanderung',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ })
+ });
+ const edit = document.querySelector('[data-testid="event-edit"]') as HTMLAnchorElement | null;
+ expect(edit).not.toBeNull();
+ expect(edit?.getAttribute('href')).toContain(EVENT_ID);
+ });
+
+ it('shows no edit affordance when eventId is null (REQ-008)', () => {
+ render(EventPill, {
+ entry: makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'PERSONAL',
+ eventId: undefined,
+ title: 'Auswanderung',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ })
+ });
+ expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
+ });
+
+ it('shows no edit affordance for a derived event (REQ-008)', () => {
+ render(EventPill, { entry: derived('MARRIAGE', 'Heirat') });
+ expect(document.querySelector('[data-testid="event-edit"]')).toBeNull();
+ });
+});
diff --git a/frontend/src/lib/timeline/GapSpan.svelte b/frontend/src/lib/timeline/GapSpan.svelte
new file mode 100644
index 00000000..ba8aa18a
--- /dev/null
+++ b/frontend/src/lib/timeline/GapSpan.svelte
@@ -0,0 +1,20 @@
+
+
+
+
+ {yearLabel} · {m.timeline_gap_empty()}
+
+
diff --git a/frontend/src/lib/timeline/GapSpan.svelte.spec.ts b/frontend/src/lib/timeline/GapSpan.svelte.spec.ts
new file mode 100644
index 00000000..57df8a33
--- /dev/null
+++ b/frontend/src/lib/timeline/GapSpan.svelte.spec.ts
@@ -0,0 +1,20 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import GapSpan from './GapSpan.svelte';
+
+afterEach(() => cleanup());
+
+describe('GapSpan', () => {
+ it('renders a multi-year empty run as "{from}–{to} · keine Einträge" (REQ-015)', () => {
+ render(GapSpan, { from: 1910, to: 1914 });
+ expect(document.body.textContent).toContain('1910–1914');
+ expect(document.body.textContent).toContain('keine Einträge');
+ });
+
+ it('renders a single empty year as "{year} · keine Einträge" (REQ-015)', () => {
+ render(GapSpan, { from: 1912, to: 1912 });
+ expect(document.body.textContent).toContain('1912');
+ expect(document.body.textContent).not.toContain('1912–1912');
+ expect(document.body.textContent).toContain('keine Einträge');
+ });
+});
diff --git a/frontend/src/lib/timeline/LetterCard.svelte b/frontend/src/lib/timeline/LetterCard.svelte
new file mode 100644
index 00000000..11a1b787
--- /dev/null
+++ b/frontend/src/lib/timeline/LetterCard.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+ {#if entry.title}
+ {entry.title}
+ {/if}
+
+ {sender}
+ →
+ {receiver}
+ {#if dateLabel}
+ · {dateLabel}
+ {/if}
+
+
diff --git a/frontend/src/lib/timeline/LetterCard.svelte.spec.ts b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts
new file mode 100644
index 00000000..28df3886
--- /dev/null
+++ b/frontend/src/lib/timeline/LetterCard.svelte.spec.ts
@@ -0,0 +1,58 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import LetterCard from './LetterCard.svelte';
+import { timelineDateLabel } from './dateLabel';
+import { makeEntry } from './test-factories';
+
+afterEach(() => cleanup());
+
+const DOC_ID = '22222222-2222-2222-2222-222222222222';
+
+describe('LetterCard', () => {
+ it('renders sender, receiver, and title', () => {
+ render(LetterCard, {
+ entry: makeEntry({ senderName: 'Karl', receiverName: 'Elfriede', title: 'Feldpost' })
+ });
+ expect(document.body.textContent).toContain('Karl');
+ expect(document.body.textContent).toContain('Elfriede');
+ expect(document.body.textContent).toContain('Feldpost');
+ });
+
+ it('renders the precision date exactly as timelineDateLabel returns (REQ-013)', () => {
+ const entry = makeEntry({ eventDate: '1915-06-15', precision: 'MONTH' });
+ const expected = timelineDateLabel(entry.eventDate, entry.precision, entry.eventDateEnd);
+ expect(expected).toBeTruthy();
+ render(LetterCard, { entry });
+ expect(document.body.textContent).toContain(expected as string);
+ });
+
+ it('renders no date chip when timelineDateLabel returns null (REQ-013)', () => {
+ const entry = makeEntry({ precision: 'UNKNOWN', eventDate: undefined });
+ render(LetterCard, { entry });
+ const chip = document.querySelector('[data-testid="letter-date"]');
+ expect(chip).toBeNull();
+ });
+
+ it('shows "Unbekannt" for an empty sender, never a bare arrow (REQ-014)', () => {
+ render(LetterCard, { entry: makeEntry({ senderName: '', receiverName: 'Elfriede' }) });
+ expect(document.body.textContent).toContain('Unbekannt');
+ });
+
+ it('shows "Unbekannt" for an empty receiver (REQ-014)', () => {
+ render(LetterCard, { entry: makeEntry({ senderName: 'Karl', receiverName: '' }) });
+ expect(document.body.textContent).toContain('Unbekannt');
+ });
+
+ it('links to exactly /documents/{documentId} with no target (REQ-023)', () => {
+ render(LetterCard, { entry: makeEntry({ documentId: DOC_ID }) });
+ const link = document.querySelector('a') as HTMLAnchorElement;
+ expect(link.getAttribute('href')).toBe(`/documents/${DOC_ID}`);
+ expect(link.hasAttribute('target')).toBe(false);
+ });
+
+ it('has a touch target of at least 44px (REQ-020)', () => {
+ render(LetterCard, { entry: makeEntry() });
+ const link = document.querySelector('a') as HTMLAnchorElement;
+ expect(link.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
+ });
+});
diff --git a/frontend/src/lib/timeline/TimelineView.svelte b/frontend/src/lib/timeline/TimelineView.svelte
new file mode 100644
index 00000000..2eef69f3
--- /dev/null
+++ b/frontend/src/lib/timeline/TimelineView.svelte
@@ -0,0 +1,107 @@
+
+
+{#if isEmpty}
+ {m.timeline_empty_state()}
+{:else}
+
+
+ {#each rows as row (row.t === 'band' ? `band-${row.year.year}` : `gap-${row.from}`)}
+ -
+ {#if row.t === 'band'}
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
+
+ {#if timeline.undated.length > 0}
+
+ {m.timeline_undated_section()}
+
+
+ {#each timeline.undated as entry (entryKey(entry))}
+ -
+ {#if entry.kind === 'EVENT'}
+ {#if entry.type === 'HISTORICAL'}
+
+ {:else}
+
+ {/if}
+ {:else}
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+{/if}
+
+
diff --git a/frontend/src/lib/timeline/TimelineView.svelte.spec.ts b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts
new file mode 100644
index 00000000..ec878a5a
--- /dev/null
+++ b/frontend/src/lib/timeline/TimelineView.svelte.spec.ts
@@ -0,0 +1,237 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import TimelineView from './TimelineView.svelte';
+import { makeEntry, makeYear, makeTimelineDTO } from './test-factories';
+
+afterEach(() => cleanup());
+
+describe('TimelineView', () => {
+ it('shows the empty state and no ol when there are no years and no undated (REQ-017)', () => {
+ render(TimelineView, { timeline: makeTimelineDTO() });
+ expect(document.body.textContent).toContain('Noch keine Ereignisse.');
+ expect(document.querySelector('ol')).toBeNull();
+ expect(document.querySelector('section')).toBeNull();
+ });
+
+ it('renders the timeline as a single with each band a , ascending (REQ-006)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1914, [makeEntry({ documentId: 'a' })]),
+ makeYear(1916, [makeEntry({ documentId: 'b' })])
+ ]
+ })
+ });
+ expect(document.querySelectorAll('ol')).toHaveLength(1);
+ const headings = Array.from(document.querySelectorAll('section h2')).map((h) => h.textContent);
+ expect(headings.some((t) => t?.includes('1914'))).toBe(true);
+ const order = headings.map((t) => t?.trim());
+ expect(order.indexOf('1914')).toBeLessThan(order.indexOf('1916'));
+ });
+
+ it('folds an interior run of empty years into one GapSpan (REQ-015)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1909, [makeEntry({ documentId: 'a' })]),
+ makeYear(1915, [makeEntry({ documentId: 'b' })])
+ ]
+ })
+ });
+ expect(document.body.textContent).toContain('1910–1914');
+ expect(document.body.textContent).toContain('keine Einträge');
+ });
+
+ it('folds a single empty interior year as a single year (REQ-015)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1911, [makeEntry({ documentId: 'a' })]),
+ makeYear(1913, [makeEntry({ documentId: 'b' })])
+ ]
+ })
+ });
+ expect(document.body.textContent).toContain('1912');
+ expect(document.body.textContent).not.toContain('1912–1912');
+ });
+
+ it('renders an "Ohne Datum" section when undated is non-empty (REQ-016)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])],
+ undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
+ })
+ });
+ expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
+ expect(document.body.textContent).toContain('Ohne Datum');
+ });
+
+ it('omits the "Ohne Datum" section from the DOM when undated is empty (REQ-016)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({ years: [makeYear(1914, [makeEntry({ documentId: 'a' })])] })
+ });
+ expect(document.querySelector('[data-testid="undated-section"]')).toBeNull();
+ });
+
+ it('renders all years and undated entries with personId undefined, no filtering (REQ-025)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1914, [makeEntry({ documentId: 'a' })]),
+ makeYear(1915, [makeEntry({ documentId: 'b' })])
+ ],
+ undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
+ }),
+ personId: undefined
+ });
+ // Two year bands inside the , plus the separate undated section.
+ expect(document.querySelectorAll('ol section h2')).toHaveLength(2);
+ expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
+ });
+
+ it('renders an undated curated EVENT as a pill, not a broken letter card (REQ-008/016)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ undated: [
+ makeEntry({
+ kind: 'EVENT',
+ type: 'PERSONAL',
+ derived: false,
+ eventId: 'e1',
+ precision: 'UNKNOWN',
+ eventDate: undefined,
+ title: 'Auswanderung',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ })
+ ]
+ })
+ });
+ // The event renders inside the undated section…
+ expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
+ expect(document.body.textContent).toContain('Auswanderung');
+ // …as an EventPill (its edit affordance), never as a letter card linking
+ // to /documents/undefined with "Unbekannt → Unbekannt".
+ expect(document.querySelector('[data-testid="event-edit"]')).not.toBeNull();
+ expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
+ expect(document.body.textContent).not.toContain('Unbekannt');
+ });
+
+ it('renders an undated HISTORICAL EVENT as a world band, not a letter card (REQ-009/016)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ undated: [
+ makeEntry({
+ kind: 'EVENT',
+ type: 'HISTORICAL',
+ derived: false,
+ precision: 'UNKNOWN',
+ eventDate: undefined,
+ title: 'Weltwirtschaftskrise',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ })
+ ]
+ })
+ });
+ expect(document.querySelector('[data-testid="undated-section"]')).not.toBeNull();
+ expect(document.body.textContent).toContain('Weltwirtschaftskrise');
+ // HISTORICAL → WorldBand carries the sr-only "Weltgeschehen" cue (REQ-018),
+ // not a broken document link.
+ expect(document.body.textContent).toContain('Weltgeschehen');
+ expect(document.querySelector('a[href="/documents/undefined"]')).toBeNull();
+ });
+
+ it('still renders an undated LETTER as a letter card (REQ-016)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ undated: [makeEntry({ precision: 'UNKNOWN', eventDate: undefined, documentId: 'u1' })]
+ })
+ });
+ expect(document.querySelector('a[href="/documents/u1"]')).not.toBeNull();
+ });
+
+ it('renders two derived events in one band without key collision (no-double-null-key)', () => {
+ const a = makeEntry({
+ kind: 'EVENT',
+ derived: true,
+ derivedType: 'BIRTH',
+ title: 'Geburt: Anna',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined,
+ eventId: undefined,
+ linkedPersonIds: ['p1']
+ });
+ const b = makeEntry({
+ kind: 'EVENT',
+ derived: true,
+ derivedType: 'BIRTH',
+ title: 'Geburt: Bertha',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined,
+ eventId: undefined,
+ linkedPersonIds: ['p2']
+ });
+ render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1915, [a, b])] }) });
+ expect(document.body.textContent).toContain('Geburt: Anna');
+ expect(document.body.textContent).toContain('Geburt: Bertha');
+ });
+
+ it('shows the redundant non-color cue label for each layer (REQ-018)', () => {
+ render(TimelineView, {
+ timeline: makeTimelineDTO({
+ years: [
+ makeYear(1914, [
+ makeEntry({
+ kind: 'EVENT',
+ derived: true,
+ derivedType: 'BIRTH',
+ title: 'Geburt: Hans',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ }),
+ makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'PERSONAL',
+ eventId: 'e1',
+ title: 'Auswanderung',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ }),
+ makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'HISTORICAL',
+ precision: 'RANGE',
+ eventDate: '1914-01-01',
+ eventDateEnd: '1918-12-31',
+ title: 'Erster Weltkrieg',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ })
+ ])
+ ]
+ })
+ });
+ expect(document.body.textContent).toContain('Weltgeschehen');
+ expect(document.body.textContent).toContain('Familie');
+ expect(document.body.textContent).toContain('Geburt');
+ });
+
+ it('places consecutive letter cards on alternating sides (REQ-004 surrogate)', () => {
+ const letters = Array.from({ length: 4 }, (_, i) => makeEntry({ documentId: `d${i}` }));
+ render(TimelineView, { timeline: makeTimelineDTO({ years: [makeYear(1909, letters)] }) });
+ const sides = Array.from(document.querySelectorAll('.letter-row')).map((el) =>
+ el.getAttribute('data-side')
+ );
+ expect(sides).toEqual(['left', 'right', 'left', 'right']);
+ });
+});
diff --git a/frontend/src/lib/timeline/WorldBand.svelte b/frontend/src/lib/timeline/WorldBand.svelte
new file mode 100644
index 00000000..92519535
--- /dev/null
+++ b/frontend/src/lib/timeline/WorldBand.svelte
@@ -0,0 +1,43 @@
+
+
+
+
+ {config.glyph}
+ {config.label}
+ {entry.title}
+
+ {#if showSpan && fromYear && toYear}
+
+ {fromYear}–{toYear}
+
+ {:else if dateText}
+ {dateText}
+ {/if}
+
diff --git a/frontend/src/lib/timeline/WorldBand.svelte.spec.ts b/frontend/src/lib/timeline/WorldBand.svelte.spec.ts
new file mode 100644
index 00000000..497f51b3
--- /dev/null
+++ b/frontend/src/lib/timeline/WorldBand.svelte.spec.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import WorldBand from './WorldBand.svelte';
+import { makeEntry } from './test-factories';
+
+afterEach(() => cleanup());
+
+function historical(overrides = {}) {
+ return makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'HISTORICAL',
+ title: 'Erster Weltkrieg',
+ senderName: '',
+ receiverName: '',
+ precision: 'RANGE',
+ eventDate: '1914-01-01',
+ eventDateEnd: '1918-12-31',
+ documentId: undefined,
+ ...overrides
+ });
+}
+
+describe('WorldBand', () => {
+ it('renders the historical title with the world glyph + "Weltgeschehen" cue (REQ-018)', () => {
+ render(WorldBand, { entry: historical() });
+ expect(document.body.textContent).toContain('Erster Weltkrieg');
+ const hidden = document.querySelector('[aria-hidden="true"]');
+ expect(hidden?.textContent).toBe('◍');
+ const srOnly = document.querySelector('.sr-only');
+ expect(srOnly?.textContent).toBe('Weltgeschehen');
+ });
+
+ it('renders a RANGE span pill 1914–1918 with a Zeitraum aria-label (REQ-009)', () => {
+ render(WorldBand, { entry: historical() });
+ const pill = document.querySelector('[data-testid="world-range"]');
+ expect(pill).not.toBeNull();
+ expect(pill?.textContent).toContain('1914–1918');
+ expect(pill?.getAttribute('aria-label')).toBe('Zeitraum: 1914 bis 1918');
+ });
+
+ it('degrades a RANGE with no end to the start year, no span pill, no crash (REQ-010)', () => {
+ render(WorldBand, { entry: historical({ eventDateEnd: undefined }) });
+ expect(document.querySelector('[data-testid="world-range"]')).toBeNull();
+ expect(document.body.textContent).toContain('Erster Weltkrieg');
+ expect(document.body.textContent).toContain('1914');
+ });
+});
diff --git a/frontend/src/lib/timeline/YearBand.svelte b/frontend/src/lib/timeline/YearBand.svelte
new file mode 100644
index 00000000..a612a750
--- /dev/null
+++ b/frontend/src/lib/timeline/YearBand.svelte
@@ -0,0 +1,99 @@
+
+
+
+
+ {year.year}
+
+
+
+ {#each rows as row (row.t === 'strip' ? `strip-${year.year}` : entryKey(row.entry))}
+ {#if row.t === 'event'}
+ {#if row.entry.type === 'HISTORICAL'}
+
+ {:else}
+
+ {/if}
+ {:else if row.t === 'letter'}
+
+
+
+ {:else}
+
+ {/if}
+ {/each}
+
+
+
+
diff --git a/frontend/src/lib/timeline/YearBand.svelte.spec.ts b/frontend/src/lib/timeline/YearBand.svelte.spec.ts
new file mode 100644
index 00000000..5d43a6e5
--- /dev/null
+++ b/frontend/src/lib/timeline/YearBand.svelte.spec.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import YearBand from './YearBand.svelte';
+import { makeEntry, makeYear } from './test-factories';
+
+afterEach(() => cleanup());
+
+function manyLetters(year: number, count: number) {
+ return Array.from({ length: count }, (_, i) =>
+ makeEntry({ eventDate: `${year}-01-10`, documentId: `doc-${i}` })
+ );
+}
+
+describe('YearBand', () => {
+ it('renders a section with a sticky h2 at top:4rem showing the year (REQ-006)', () => {
+ render(YearBand, { year: makeYear(1914, [makeEntry()]) });
+ const section = document.querySelector('section');
+ expect(section).not.toBeNull();
+ const h2 = section?.querySelector('h2');
+ expect(h2?.textContent).toContain('1914');
+ const cs = getComputedStyle(h2 as HTMLElement);
+ expect(cs.position).toBe('sticky');
+ expect(cs.top).toBe('64px');
+ });
+
+ it('renders each letter as a card when the band holds <= 12 letters (REQ-011)', () => {
+ render(YearBand, { year: makeYear(1909, manyLetters(1909, 3)) });
+ expect(document.querySelectorAll('a')).toHaveLength(3);
+ expect(document.querySelector('[data-testid="strip-expand"]')).toBeNull();
+ });
+
+ it('renders a single strip when the band holds > 12 letters (REQ-012)', () => {
+ render(YearBand, { year: makeYear(1915, manyLetters(1915, 30)) });
+ expect(document.querySelector('[data-testid="strip-expand"]')).not.toBeNull();
+ // collapsed: no individual letter links yet
+ expect(document.querySelectorAll('a')).toHaveLength(0);
+ });
+
+ it('renders entries in DTO order — DAY-precision letter above a YEAR-precision letter (REQ-003)', () => {
+ const dayLetter = makeEntry({
+ precision: 'DAY',
+ eventDate: '1923-04-12',
+ title: 'Tagesgenau',
+ documentId: 'day'
+ });
+ const yearLetter = makeEntry({
+ precision: 'YEAR',
+ eventDate: '1923-01-01',
+ title: 'Nur Jahr',
+ documentId: 'year'
+ });
+ render(YearBand, { year: makeYear(1923, [dayLetter, yearLetter]) });
+ const links = Array.from(document.querySelectorAll('a'));
+ expect(links[0].getAttribute('href')).toBe('/documents/day');
+ expect(links[1].getAttribute('href')).toBe('/documents/year');
+ });
+
+ it('renders an EVENT as a pill and a HISTORICAL event as a band', () => {
+ const pill = makeEntry({
+ kind: 'EVENT',
+ derived: true,
+ derivedType: 'MARRIAGE',
+ title: 'Heirat',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ });
+ const band = makeEntry({
+ kind: 'EVENT',
+ derived: false,
+ type: 'HISTORICAL',
+ precision: 'RANGE',
+ eventDate: '1914-01-01',
+ eventDateEnd: '1918-12-31',
+ title: 'Erster Weltkrieg',
+ senderName: '',
+ receiverName: '',
+ documentId: undefined
+ });
+ render(YearBand, { year: makeYear(1914, [pill, band]) });
+ expect(document.body.textContent).toContain('Heirat');
+ expect(document.querySelector('[data-testid="world-range"]')).not.toBeNull();
+ });
+});
diff --git a/frontend/src/lib/timeline/YearLetterStrip.svelte b/frontend/src/lib/timeline/YearLetterStrip.svelte
new file mode 100644
index 00000000..8409734c
--- /dev/null
+++ b/frontend/src/lib/timeline/YearLetterStrip.svelte
@@ -0,0 +1,52 @@
+
+
+
+
+ {m.timeline_letters_count({ count: letters.length })}
+
+
+
+
+
+ {#if expanded}
+
+ {#each letters as letter (letter.documentId)}
+
+ {/each}
+
+ {/if}
+
diff --git a/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts b/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts
new file mode 100644
index 00000000..4c78ea0e
--- /dev/null
+++ b/frontend/src/lib/timeline/YearLetterStrip.svelte.spec.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { tick } from 'svelte';
+import YearLetterStrip from './YearLetterStrip.svelte';
+import { makeEntry } from './test-factories';
+
+afterEach(() => cleanup());
+
+function denseLetters(year: number, count: number) {
+ return Array.from({ length: count }, (_, i) =>
+ makeEntry({
+ eventDate: `${year}-${String((i % 12) + 1).padStart(2, '0')}-10`,
+ documentId: `doc-${i}`
+ })
+ );
+}
+
+describe('YearLetterStrip', () => {
+ it('shows the letter count and a 12-bar sparkline (REQ-012)', () => {
+ render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
+ expect(document.body.textContent).toContain('30');
+ const bars = document.querySelectorAll('[data-testid="sparkline-bar"]');
+ expect(bars).toHaveLength(12);
+ });
+
+ it('has a keyboard-focusable expand toggle of at least 44px (REQ-012)', () => {
+ render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
+ const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
+ expect(toggle).not.toBeNull();
+ expect(toggle.tagName).toBe('BUTTON');
+ expect(toggle.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
+ });
+
+ it('reveals all letter cards when expanded (REQ-012)', async () => {
+ render(YearLetterStrip, { letters: denseLetters(1915, 30), year: 1915 });
+ expect(document.querySelectorAll('a').length).toBe(0);
+ const toggle = document.querySelector('[data-testid="strip-expand"]') as HTMLButtonElement;
+ toggle.click();
+ await tick();
+ expect(document.querySelectorAll('a').length).toBe(30);
+ });
+});
diff --git a/frontend/src/lib/timeline/entryKey.ts b/frontend/src/lib/timeline/entryKey.ts
new file mode 100644
index 00000000..4ebd74de
--- /dev/null
+++ b/frontend/src/lib/timeline/entryKey.ts
@@ -0,0 +1,23 @@
+import type { components } from '$lib/generated/api';
+
+type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
+
+/**
+ * Stable `{#each}` key for a timeline entry. Prefers the entry's own identity
+ * (`eventId` for curated events, `documentId` for letters); derived life-events
+ * carry neither, so they key on `derivedType` + their linked person ids — which
+ * keeps two derived births in the same year distinct. The `kind` prefix keeps an
+ * event and a letter that happen to share an id from colliding.
+ *
+ * Used by both `YearBand` (per-band rows) and `TimelineView` (the undated
+ * bucket), where entries can be events without a `documentId`.
+ */
+export function entryKey(entry: TimelineEntryDTO): string {
+ return (
+ entry.kind +
+ ':' +
+ (entry.eventId ??
+ entry.documentId ??
+ `${entry.derivedType}:${(entry.linkedPersonIds ?? []).join('-')}`)
+ );
+}
diff --git a/frontend/src/lib/timeline/eventCardConfig.spec.ts b/frontend/src/lib/timeline/eventCardConfig.spec.ts
new file mode 100644
index 00000000..8fd33355
--- /dev/null
+++ b/frontend/src/lib/timeline/eventCardConfig.spec.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect } from 'vitest';
+import { getAccentConfig } from './eventCardConfig';
+import type { components } from '$lib/generated/api';
+
+type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
+
+function event(overrides: Partial): TimelineEntryDTO {
+ return {
+ kind: 'EVENT',
+ precision: 'YEAR',
+ derived: false,
+ senderName: '',
+ receiverName: '',
+ ...overrides
+ };
+}
+
+describe('getAccentConfig', () => {
+ it('maps a derived birth to the * glyph and "Geburt"', () => {
+ const cfg = getAccentConfig(event({ derived: true, derivedType: 'BIRTH' }));
+ expect(cfg.glyph).toBe('*');
+ expect(cfg.label).toBe('Geburt');
+ expect(cfg.accent).toBe('derived');
+ });
+
+ it('maps a derived death to the † glyph and "Tod"', () => {
+ const cfg = getAccentConfig(event({ derived: true, derivedType: 'DEATH' }));
+ expect(cfg.glyph).toBe('†');
+ expect(cfg.label).toBe('Tod');
+ expect(cfg.accent).toBe('derived');
+ });
+
+ it('maps a derived marriage to the ⚭ glyph and "Heirat"', () => {
+ const cfg = getAccentConfig(event({ derived: true, derivedType: 'MARRIAGE' }));
+ expect(cfg.glyph).toBe('⚭');
+ expect(cfg.label).toBe('Heirat');
+ expect(cfg.accent).toBe('derived');
+ });
+
+ it('maps a HISTORICAL event to the world glyph and "Weltgeschehen"', () => {
+ const cfg = getAccentConfig(event({ type: 'HISTORICAL' }));
+ expect(cfg.glyph).toBe('◍');
+ expect(cfg.label).toBe('Weltgeschehen');
+ expect(cfg.accent).toBe('historical');
+ });
+
+ it('maps a curated PERSONAL event to the ★ glyph and "Familie"', () => {
+ const cfg = getAccentConfig(event({ type: 'PERSONAL', eventId: 'e-1' }));
+ expect(cfg.glyph).toBe('★');
+ expect(cfg.label).toBe('Familie');
+ expect(cfg.accent).toBe('curated');
+ });
+});
diff --git a/frontend/src/lib/timeline/eventCardConfig.ts b/frontend/src/lib/timeline/eventCardConfig.ts
new file mode 100644
index 00000000..cb11d7b2
--- /dev/null
+++ b/frontend/src/lib/timeline/eventCardConfig.ts
@@ -0,0 +1,38 @@
+import * as m from '$lib/paraglide/messages.js';
+import type { components } from '$lib/generated/api';
+
+type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
+
+/** Styling discriminant for an axis pill/band. */
+export type TimelineAccent = 'derived' | 'curated' | 'historical';
+
+export interface AccentConfig {
+ /** Visible Unicode glyph — render `aria-hidden`, paired with an sr-only label. */
+ glyph: string;
+ /** Localized layer/life-event label — used as the sr-only / aria text only. */
+ label: string;
+ accent: TimelineAccent;
+}
+
+/**
+ * Maps a timeline EVENT entry to its glyph, redundant non-color label, and accent
+ * (REQ-007/008/018). Derived life-events use the * / † / ⚭ glyphs that match
+ * `personLifeDates.ts`; HISTORICAL events get the muted world band; everything
+ * else (curated PERSONAL) gets the mint family pill.
+ */
+export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
+ if (entry.derived) {
+ switch (entry.derivedType) {
+ case 'BIRTH':
+ return { glyph: '*', label: m.timeline_derived_birth(), accent: 'derived' };
+ case 'DEATH':
+ return { glyph: '†', label: m.timeline_derived_death(), accent: 'derived' };
+ case 'MARRIAGE':
+ return { glyph: '⚭', label: m.timeline_derived_marriage(), accent: 'derived' };
+ }
+ }
+ if (entry.type === 'HISTORICAL') {
+ return { glyph: '◍', label: m.timeline_layer_world(), accent: 'historical' };
+ }
+ return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
+}
diff --git a/frontend/src/lib/timeline/test-factories.ts b/frontend/src/lib/timeline/test-factories.ts
new file mode 100644
index 00000000..25bc3026
--- /dev/null
+++ b/frontend/src/lib/timeline/test-factories.ts
@@ -0,0 +1,34 @@
+import type { components } from '$lib/generated/api';
+
+type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
+type TimelineYearDTO = components['schemas']['TimelineYearDTO'];
+type TimelineDTO = components['schemas']['TimelineDTO'];
+
+/**
+ * Builds a `TimelineEntryDTO` mirroring the real wire shape (no `year`,
+ * `description`, or `snippet` fields). Defaults to a dated DAY-precision letter;
+ * override `kind`/`derived`/`type`/`derivedType` etc. for events.
+ */
+export function makeEntry(overrides: Partial = {}): TimelineEntryDTO {
+ return {
+ kind: 'LETTER',
+ precision: 'DAY',
+ derived: false,
+ senderName: 'Karl Raddatz',
+ receiverName: 'Elfriede Raddatz',
+ eventDate: '1915-06-15',
+ title: 'Brief aus dem Feld',
+ documentId: '11111111-1111-1111-1111-111111111111',
+ ...overrides
+ };
+}
+
+export function makeYear(year: number, entries: TimelineEntryDTO[]): TimelineYearDTO {
+ return { year, entries };
+}
+
+export function makeTimelineDTO(
+ opts: { years?: TimelineYearDTO[]; undated?: TimelineEntryDTO[] } = {}
+): TimelineDTO {
+ return { years: opts.years ?? [], undated: opts.undated ?? [] };
+}
diff --git a/frontend/src/lib/timeline/timelineDensity.spec.ts b/frontend/src/lib/timeline/timelineDensity.spec.ts
new file mode 100644
index 00000000..d7157241
--- /dev/null
+++ b/frontend/src/lib/timeline/timelineDensity.spec.ts
@@ -0,0 +1,110 @@
+import { describe, it, expect } from 'vitest';
+import { isDense, monthHistogram, DENSE_THRESHOLD } from './timelineDensity';
+import type { components } from '$lib/generated/api';
+
+type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
+
+function letter(eventDate: string): TimelineEntryDTO {
+ return {
+ kind: 'LETTER',
+ precision: 'DAY',
+ derived: false,
+ senderName: 'Karl',
+ receiverName: 'Elfriede',
+ eventDate
+ };
+}
+
+describe('isDense', () => {
+ it('uses a threshold of 12', () => {
+ expect(DENSE_THRESHOLD).toBe(12);
+ });
+
+ it('is false at exactly 12 letters (still rendered as individual cards)', () => {
+ expect(isDense(12)).toBe(false);
+ });
+
+ it('is true above 12 letters (collapses to a strip)', () => {
+ expect(isDense(13)).toBe(true);
+ });
+
+ it('is false for empty and small bands', () => {
+ expect(isDense(0)).toBe(false);
+ expect(isDense(3)).toBe(false);
+ });
+});
+
+describe('monthHistogram', () => {
+ it('returns exactly 12 buckets for the band year, Jan..Dec', () => {
+ const buckets = monthHistogram([letter('1915-03-04')], 1915);
+ expect(buckets).toHaveLength(12);
+ expect(buckets.map((b) => b.month)).toEqual([
+ '1915-01',
+ '1915-02',
+ '1915-03',
+ '1915-04',
+ '1915-05',
+ '1915-06',
+ '1915-07',
+ '1915-08',
+ '1915-09',
+ '1915-10',
+ '1915-11',
+ '1915-12'
+ ]);
+ });
+
+ it('counts each letter on its eventDate month; counts sum to the total', () => {
+ // 30 letters spread one-or-more per month across 1915.
+ const dist: Record = {
+ '01': 1,
+ '02': 2,
+ '03': 3,
+ '04': 4,
+ '05': 1,
+ '06': 5,
+ '07': 2,
+ '08': 6,
+ '09': 1,
+ '10': 2,
+ '11': 2,
+ '12': 1
+ };
+ const letters: TimelineEntryDTO[] = [];
+ for (const [mm, n] of Object.entries(dist)) {
+ for (let i = 0; i < n; i++) letters.push(letter(`1915-${mm}-10`));
+ }
+ expect(letters).toHaveLength(30);
+
+ const buckets = monthHistogram(letters, 1915);
+ expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(30);
+ for (const b of buckets) {
+ expect(b.count).toBe(dist[b.month.slice(5)]);
+ }
+ });
+
+ it('yields height 0 for the eleven empty months when letters cluster in one', () => {
+ const buckets = monthHistogram([letter('1915-03-01'), letter('1915-03-28')], 1915);
+ const march = buckets.find((b) => b.month === '1915-03');
+ expect(march?.count).toBe(2);
+ expect(buckets.filter((b) => b.month !== '1915-03').every((b) => b.count === 0)).toBe(true);
+ });
+
+ it('counts coarser-than-month precisions on their eventDate anchor month', () => {
+ const seasonLetter: TimelineEntryDTO = { ...letter('1915-07-01'), precision: 'SEASON' };
+ const buckets = monthHistogram([seasonLetter], 1915);
+ expect(buckets.find((b) => b.month === '1915-07')?.count).toBe(1);
+ });
+
+ it('ignores entries without an eventDate', () => {
+ const undated: TimelineEntryDTO = {
+ kind: 'LETTER',
+ precision: 'UNKNOWN',
+ derived: false,
+ senderName: 'Karl',
+ receiverName: 'Elfriede'
+ };
+ const buckets = monthHistogram([undated, letter('1915-05-01')], 1915);
+ expect(buckets.reduce((sum, b) => sum + b.count, 0)).toBe(1);
+ });
+});
diff --git a/frontend/src/lib/timeline/timelineDensity.ts b/frontend/src/lib/timeline/timelineDensity.ts
new file mode 100644
index 00000000..5dab1641
--- /dev/null
+++ b/frontend/src/lib/timeline/timelineDensity.ts
@@ -0,0 +1,32 @@
+import type { components } from '$lib/generated/api';
+import { fillDensityGaps, type MonthBucket } from '$lib/shared/utils/monthBuckets';
+
+type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
+
+/**
+ * A year band with more letters than this renders as a compact density strip
+ * (count + 12-month sparkline) instead of one card per letter (REQ-012).
+ */
+export const DENSE_THRESHOLD = 12;
+
+export function isDense(letterCount: number): boolean {
+ return letterCount > DENSE_THRESHOLD;
+}
+
+/**
+ * Buckets a band's letters into exactly 12 month buckets (`{year}-01`..`{year}-12`)
+ * for the density sparkline. Each letter counts on its `eventDate` month; coarser
+ * precisions (SEASON/YEAR/APPROX) count on whatever anchor month the backend put
+ * in `eventDate`. Entries without an `eventDate` (e.g. UNKNOWN) are ignored — they
+ * live in the "Ohne Datum" bucket, not a dated band. (REQ-027)
+ */
+export function monthHistogram(letters: TimelineEntryDTO[], year: number): MonthBucket[] {
+ const counts = new Map();
+ for (const l of letters) {
+ if (!l.eventDate) continue;
+ const month = l.eventDate.slice(0, 7); // YYYY-MM
+ counts.set(month, (counts.get(month) ?? 0) + 1);
+ }
+ const buckets = Array.from(counts.entries()).map(([month, count]) => ({ month, count }));
+ return fillDensityGaps(buckets, `${year}-01-01`, `${year}-12-31`);
+}
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') }
+ });
+ });
+});