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>
13 KiB
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:
- Person life-events derived from already-curated structured data (
Personbirth/death dates, marriage years fromPersonRelationship.fromYear). Trusted, free, no extra entry. (Requires the Person birth/death fields to move from year-integers to date + precision — see foundational issue 1.) - Hand-curated events the family writes — both personal (a move, an illness, emigration) and historical (a war, hyperinflation). Editorially controlled, always correct.
- 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 aYEAR-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
UNKNOWNprecision. - Honest precision rendering reuses the existing
DatePrecisionenum 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_documentsjoin 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();UNKNOWNprecision → "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),derivedflag, 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 justGET /api/timeline?personId=…— no separate endpoint. RequiresREAD_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. RequiresREAD_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 optionalpersonIdprop so the same component powers both global and per-person views.YearBand.svelte— one year section header + its entries.EventCard.svelte— renders aPERSONAL/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/extendlib/document/timeline.tshelpers likeformatTickLabelwhere they fit).
- Routes:
/zeitstrahl— global timeline (+page.server.tsloads/api/timeline)./zeitstrahl/events/newand/zeitstrahl/events/[id]/edit— curator forms, gated toWRITE_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-seriffor names/titles,BackButton, mobile-first at 375px, dark-mode tokens). - i18n keys added to
messages/{de,en,es}.json(German primary).
Testing
- Backend:
TimelineServiceassembly/merge/sort/precision-bucketing (unit +@DataJpaTestagainst Postgres via Testcontainers); controller permission gating; derived-event assembly (birth/death/marriage, symmetric marriage dedup). - Frontend:
dateLabel.tsprecision rendering;TimelineViewglobal vspersonIdmodes (*.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.
- Person birth/death → date + precision (foundational) — replace
birthYear/deathYearwithbirthDate/deathDate+ precision onPerson; Flyway data migration (year →YYYY-01-01,YEAR); update importer with re-import preservation rule; derive year inPersonNodeDTO(Stammbaum untouched); move person card / hover card to a precision-awarepersonLifeDates.ts; add date+precision inputs to person new/edit forms. Ships value on its own. - Backend:
TimelineEvententity + migration — entity,EventType, Flyway migration + join tables, repository. - Backend: TimelineEvent CRUD API —
TimelineEventController+TimelineServicewrite methods,TimelineEventRequestDTO, permission gating,GET /events/{id}. - Backend: derived person-events — assemble Geburt/Tod/Heirat from migrated Person + relationship data via their services; unit-tested dedup.
- Backend: timeline assembly endpoint —
GET /api/timelinemerging events + derived events + letters intoTimelineDTO; year-bucketing, precision sort, undated bucket, filters. - Frontend: shared date-label helper + types —
dateLabel.ts, regen API types. - Frontend: global
/zeitstrahlview —TimelineView,YearBand,EventCard,LetterCard, server load. - Frontend: filters —
TimelineFilters(person / generation / layer / year range). - Frontend: curator event forms —
/zeitstrahl/events/new+/[id]/edit, gated, with document & person pickers. - Frontend: per-person Lebensweg — embed
<TimelineView personId>on Person detail. - 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 (perdocs/CLAUDE.md, significant data-model change). Add as the next sequential ADR number when implementation starts.