Files
familienarchiv/docs/superpowers/specs/2026-06-07-family-timeline-design.md
Marcel ebe4198be9 docs(timeline): add Person date+precision migration as foundational issue
Replace Person birthYear/deathYear integers with birthDate/deathDate +
DatePrecision so known exact birthdays render precisely. Migration,
re-import preservation rule, and bounded blast radius captured; becomes
issue 1 the timeline's derived events depend on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:03:12 +02:00

183 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` | startend year | `19141918` |
| `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 #750753).
### `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.