Files
familienarchiv/docs/superpowers/specs/2026-06-07-family-timeline-design.md
Marcel 57aeb1ec7b 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>
2026-06-12 10:03:12 +02:00

11 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.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 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.

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