docs(timeline): add family timeline (Zeitstrahl) design spec
Hand-curated, year-banded vertical timeline weaving derived person life-events, curated personal/historical events, and date-placed letters. Includes proposed sub-issue breakdown for a milestone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
160
docs/superpowers/specs/2026-06-07-family-timeline-design.md
Normal file
160
docs/superpowers/specs/2026-06-07-family-timeline-design.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# 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.birthYear`/`deathYear`, marriage years from `PersonRelationship.fromYear`). Trusted, free, no extra entry.
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Derived person-events (not persisted)
|
||||||
|
|
||||||
|
Assembled on read from existing curated data; never stored:
|
||||||
|
|
||||||
|
| Source | Derived event | `eventDate` | precision |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Person.birthYear` | *Geburt: {name}* | `{birthYear}-01-01` | `YEAR` |
|
||||||
|
| `Person.deathYear` | *Tod: {name}* | `{deathYear}-01-01` | `YEAR` |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
1. **Backend: `TimelineEvent` entity + migration** — entity, `EventType`, Flyway migration + join tables, repository. (foundation)
|
||||||
|
2. **Backend: TimelineEvent CRUD API** — `TimelineEventController` + `TimelineService` write methods, `TimelineEventRequest` DTO, permission gating, `GET /events/{id}`.
|
||||||
|
3. **Backend: derived person-events** — assemble Geburt/Tod/Heirat from Person + relationship data via their services; unit-tested dedup.
|
||||||
|
4. **Backend: timeline assembly endpoint** — `GET /api/timeline` merging events + derived events + letters into `TimelineDTO`; year-bucketing, precision sort, undated bucket, filters.
|
||||||
|
5. **Frontend: shared date-label helper + types** — `dateLabel.ts`, regen API types.
|
||||||
|
6. **Frontend: global `/zeitstrahl` view** — `TimelineView`, `YearBand`, `EventCard`, `LetterCard`, server load.
|
||||||
|
7. **Frontend: filters** — `TimelineFilters` (person / generation / layer / year range).
|
||||||
|
8. **Frontend: curator event forms** — `/zeitstrahl/events/new` + `/[id]/edit`, gated, with document & person pickers.
|
||||||
|
9. **Frontend: per-person Lebensweg** — embed `<TimelineView personId>` on Person detail.
|
||||||
|
10. **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.
|
||||||
Reference in New Issue
Block a user