# 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 `<TimelineView personId={person.id} />`.
- 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 `<TimelineView personId>` 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.
