Files
familienarchiv/docs/superpowers/specs/2026-06-07-family-timeline-design.md
Marcel e63eaadc33 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-07 19:26:13 +02:00

13 KiB
Raw Blame History

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_yearbirth_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 APITimelineEventController + 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 endpointGET /api/timeline merging events + derived events + letters into TimelineDTO; year-bucketing, precision sort, undated bucket, filters.
  6. Frontend: shared date-label helper + typesdateLabel.ts, regen API types.
  7. Frontend: global /zeitstrahl viewTimelineView, YearBand, EventCard, LetterCard, server load.
  8. Frontend: filtersTimelineFilters (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.