# Family Timeline (Zeitstrahl) — Design Spec **Date:** 2026-06-07 **Status:** Approved — pending implementation plan ## Problem The archive can capture, transcribe, organize, and browse letters, but the transcribed material does not yet add up to a *story in time*. Readers (younger, phone-first) have no way to feel the family's history unfold; transcribers don't see their work become something larger. A previous attempt to derive meaning automatically (LLM search) was slow and low-quality, so the family is wary of auto-extraction from handwriting. ## Goal A **hand-curated, year-banded vertical timeline** — the "Zeitstrahl" — that weaves three layers into one chronological view: 1. **Person life-events** derived from already-curated structured data (`Person` birth/death dates, marriage years from `PersonRelationship.fromYear`). Trusted, free, no extra entry. (Requires the Person birth/death fields to move from year-integers to date + precision — see foundational issue 1.) 2. **Hand-curated events** the family writes — both **personal** (a move, an illness, emigration) and **historical** (a war, hyperinflation). Editorially controlled, always correct. 3. **Letters**, auto-placed by their existing `documentDate`, optionally hand-linked to an event to cluster them. Two surfaces, one component: - **Global timeline** at `/zeitstrahl`. - **Per-person "Lebensweg"** — the same view filtered to one person, embedded on the Person detail page. Built for phones (vertical scroll), honest about date precision, with no fabricated dates. ### Non-goals (YAGNI) - ❌ Auto-extracting events from transcription text — explicitly avoided; this is what makes the feature trustworthy. - ❌ Importing an external historical-events dataset — historical events are hand-entered too. - ❌ A map / geographic view — that is a separate future feature (B2). - ❌ Per-derived-event hide/override toggle — deferred refinement; MVP shows all derived events. - ❌ Day-resolution timeline axis — the axis is the **year**; finer dates only affect within-band ordering and label text. ## Core principle: the year is the axis Most dates in the archive are year-only (birth/death/marriage years are years by nature; many letters carry `YEAR`/`APPROX` precision). Therefore: - The timeline spine is a **sequence of year bands**. Everything for a given year lives in that band. - **Finer ordering only when we have it.** A `DAY`-precision letter (`1923-04-12`) sorts above a `YEAR`-precision one (`1923`) *within* the 1923 band; we never invent a day we don't have. - **An "Ohne Datum" bucket** at the end holds items with `UNKNOWN` precision. - **Honest precision rendering** reuses the existing `DatePrecision` enum for every dated item (events and letters share one rendering path). ### Date rendering (shared by events and letters) | `DatePrecision` | German render | Example | |---|---|---| | `DAY` | full date | `28. Juli 1914` | | `MONTH` | month + year | `Juli 1914` | | `SEASON` | season + year | `Sommer 1914` | | `YEAR` | year only | `1914` | | `APPROX` | "ca." + year | `ca. 1914` | | `RANGE` | start–end year | `1914–1918` | | `UNKNOWN` | undated bucket | `Ohne Datum` | A `RANGE` item is shown in its **start year's band** with a span marker; it is not duplicated across every year it covers. ## Data model A new `timeline/` domain package on the backend (kept deliberately separate from the in-flight Lesereisen/`Geschichte` work in #750–753). ### `TimelineEvent` entity Mirrors the `Document` date model for consistency, so events and letters use one date-handling code path. | Field | Type | Notes | |---|---|---| | `id` | `UUID` | `@GeneratedValue(UUID)` | | `title` | `String` | required | | `type` | `EventType` enum | `PERSONAL`, `HISTORICAL` | | `eventDate` | `LocalDate` | required — most precise date known (WW1 → `1914-07-28`; vague year → `1920-01-01`) | | `precision` | `DatePrecision` | reuse existing enum; default `YEAR` — governs rendering & whether the day matters | | `eventDateEnd` | `LocalDate` (nullable) | only set when `precision == RANGE` | | `description` | `TEXT` (nullable) | free-text narrative for the event | | `persons` | ManyToMany `Person` | who the event involves; drives the per-person view & filtering | | `documents` | ManyToMany `Document` | optional hand-linked supporting letters (the "cluster letters to an event" feature) | | `createdBy` / `createdAt` / `updatedBy` / `updatedAt` / `version` | audit | standard entity conventions | - `@Schema(requiredMode = REQUIRED)` on every always-populated field (`id`, `title`, `type`, `eventDate`, `precision`). - Collections use `@Builder.Default new HashSet<>()`. - New Flyway migration adds `timeline_events`, `timeline_event_persons`, `timeline_event_documents` join tables. ### `EventType` enum `PERSONAL` | `HISTORICAL`. Personal events render with a person/family accent; historical events with a "world" accent and muted styling so the two layers are visually separable. ### Prerequisite: migrate `Person` birth/death to date + precision Today `Person` stores `birthYear`/`deathYear` as `Integer`, so a known exact birthday (e.g. `1901-03-14`) has nowhere to live and derived events are stuck at year precision. This is fixed by a **foundational Person-domain migration** that the timeline depends on (and which delivers value on its own — precise dates then render on person cards, hover cards, and the Stammbaum). **Change:** replace `birthYear`/`deathYear` (`Integer`) with: | Field | Type | Notes | |---|---|---| | `birthDate` | `LocalDate` (nullable) | most precise date known | | `birthDatePrecision` | `DatePrecision` (nullable) | `YEAR` for year-only, `DAY` for exact birthdays, etc. | | `deathDate` | `LocalDate` (nullable) | | | `deathDatePrecision` | `DatePrecision` (nullable) | | **Flyway data migration:** existing `birth_year` → `birth_date = '{year}-01-01'`, `birth_date_precision = 'YEAR'` (same for death); then drop the year columns. **Re-import preservation (ADR-025):** the canonical importer (`PersonRegisterImporter` / `tools/import-normalizer/persons_tree.py`) only carries the *year*. On re-import it must **not** clobber a hand-entered finer-than-`YEAR` date — if the existing precision is `DAY`/`MONTH`/`SEASON`, preserve it; only refresh from the spreadsheet year when the field is empty or still `YEAR`-from-import. **Bounding the blast radius:** `PersonNodeDTO` keeps exposing an `Integer birthYear`/`deathYear` *derived* from the new date (`birthDate.getYear()`), so the Stammbaum layout (`familyForest.ts` et al.) is untouched. Display surfaces (person card, hover card) move to a shared precision-aware formatter — extend the existing `frontend/src/lib/person/personLifeDates.ts`. The person edit/new forms gain date inputs with a precision selector. **Scope note:** `PersonRelationship.fromYear` (marriage year) stays `Integer`/`YEAR` for MVP — precise marriage dates are a later, parallel extension if wanted. ### Derived person-events (not persisted) Assembled on read from the migrated `Person` data; never stored: | Source | Derived event | `eventDate` | precision | |---|---|---|---| | `Person.birthDate` | *Geburt: {name}* | `Person.birthDate` | `Person.birthDatePrecision` | | `Person.deathDate` | *Tod: {name}* | `Person.deathDate` | `Person.deathDatePrecision` | | `PersonRelationship` `SPOUSE_OF.fromYear` | *Heirat: {A} & {B}* | `{fromYear}-01-01` | `YEAR` | Emitted in the same DTO shape as a curated event, flagged `derived: true`, `type = PERSONAL`. They cannot be edited from the timeline (they are edited at their source: Person record / relationship). A marriage is derived once per `SPOUSE_OF` edge (symmetric edges are stored once — see existing relationship rules). ### Letters Placed by `Document.documentDate`: - Band = `documentDate.getYear()`; `UNKNOWN` precision → "Ohne Datum" bucket. - Sub-ordered within a band by full date when precision allows. - A letter may also appear under an event it's linked to (via `TimelineEvent.documents`) as a cluster, in addition to its own band placement. ## Assembly & API A `TimelineService` merges the three layers into a year-bucketed DTO for the requested scope and filters. Layering rules apply: the service owns `TimelineEventRepository` and reaches Person/Document/Relationship data through their **services**, never their repositories. ### DTOs - `TimelineEntryDTO` — one renderable item: `kind` (`EVENT` | `LETTER`), `eventDate`, `precision`, `eventDateEnd`, `title`, `type` (for events), `derived` flag, plus the source id (eventId / documentId) and minimal display fields (sender/receiver names for letters, linked person ids for events). - `TimelineYearDTO` — `{ year: int, entries: TimelineEntryDTO[] }`. - `TimelineDTO` — `{ years: TimelineYearDTO[], undated: TimelineEntryDTO[] }`. ### Endpoints - `GET /api/timeline` — global timeline. Query params (all optional): `personId`, `generation`, `type` (`PERSONAL`/`HISTORICAL`), `fromYear`, `toYear`. The per-person "Lebensweg" is just `GET /api/timeline?personId=…` — no separate endpoint. Requires `READ_ALL`. - `POST /api/timeline/events` — create a curated event. `@RequirePermission(Permission.WRITE_ALL)`. - `PUT /api/timeline/events/{id}` — update. `@RequirePermission(Permission.WRITE_ALL)`. - `DELETE /api/timeline/events/{id}` — delete. `@RequirePermission(Permission.WRITE_ALL)`. - `GET /api/timeline/events/{id}` — fetch a single event for the edit form. Requires `READ_ALL`. Input DTO `TimelineEventRequest` lives flat in the `timeline/` package. Errors use `DomainException.notFound/...`; **no new `ErrorCode`** is required. Run `npm run generate:api` after backend model/endpoint changes. ## Frontend - New domain dir `frontend/src/lib/timeline/`: - `TimelineView.svelte` — orchestrator; accepts an optional `personId` prop so the same component powers both global and per-person views. - `YearBand.svelte` — one year section header + its entries. - `EventCard.svelte` — renders a `PERSONAL`/`HISTORICAL`/derived event with precision-aware date label. - `LetterCard.svelte` — compact letter row (sender → receiver, snippet/title, date), links to `/documents/[id]`. - `TimelineFilters.svelte` — person, generation, layer toggles, year range. - `dateLabel.ts` — the shared precision→label helper (reuse/extend `lib/document/timeline.ts` helpers like `formatTickLabel` where they fit). - Routes: - `/zeitstrahl` — global timeline (`+page.server.ts` loads `/api/timeline`). - `/zeitstrahl/events/new` and `/zeitstrahl/events/[id]/edit` — curator forms, gated to `WRITE_ALL`, using the form-actions pattern. - Person detail page gains a **Lebensweg** card section embedding ``. - Styling per project conventions (card pattern, brand tokens, `font-serif` for names/titles, `BackButton`, mobile-first at 375px, dark-mode tokens). - i18n keys added to `messages/{de,en,es}.json` (German primary). ## Testing - Backend: `TimelineService` assembly/merge/sort/precision-bucketing (unit + `@DataJpaTest` against Postgres via Testcontainers); controller permission gating; derived-event assembly (birth/death/marriage, symmetric marriage dedup). - Frontend: `dateLabel.ts` precision rendering; `TimelineView` global vs `personId` modes (`*.svelte.spec.ts`); filter behavior. - Follow project test discipline: targeted single-file runs locally only; full sweep left to CI. ## Proposed issue breakdown (milestone "Zeitstrahl / Family Timeline") Ordered so each issue is independently shippable and reviewable; later issues depend on earlier ones. Issue 1 is a standalone Person-domain improvement and a hard prerequisite for the timeline's derived events. 1. **Person birth/death → date + precision (foundational)** — replace `birthYear`/`deathYear` with `birthDate`/`deathDate` + precision on `Person`; Flyway data migration (year → `YYYY-01-01`, `YEAR`); update importer with re-import preservation rule; derive year in `PersonNodeDTO` (Stammbaum untouched); move person card / hover card to a precision-aware `personLifeDates.ts`; add date+precision inputs to person new/edit forms. Ships value on its own. 2. **Backend: `TimelineEvent` entity + migration** — entity, `EventType`, Flyway migration + join tables, repository. 3. **Backend: TimelineEvent CRUD API** — `TimelineEventController` + `TimelineService` write methods, `TimelineEventRequest` DTO, permission gating, `GET /events/{id}`. 4. **Backend: derived person-events** — assemble Geburt/Tod/Heirat from migrated Person + relationship data via their services; unit-tested dedup. 5. **Backend: timeline assembly endpoint** — `GET /api/timeline` merging events + derived events + letters into `TimelineDTO`; year-bucketing, precision sort, undated bucket, filters. 6. **Frontend: shared date-label helper + types** — `dateLabel.ts`, regen API types. 7. **Frontend: global `/zeitstrahl` view** — `TimelineView`, `YearBand`, `EventCard`, `LetterCard`, server load. 8. **Frontend: filters** — `TimelineFilters` (person / generation / layer / year range). 9. **Frontend: curator event forms** — `/zeitstrahl/events/new` + `/[id]/edit`, gated, with document & person pickers. 10. **Frontend: per-person Lebensweg** — embed `` on Person detail. 11. **Polish & a11y** — mobile layout at 375px, dark mode, axe checks, i18n completeness (de/en/es). > An ADR may be warranted for the new `timeline/` domain + entity (per `docs/CLAUDE.md`, significant data-model change). Add as the next sequential ADR number when implementation starts.