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>
11 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 (
Person.birthYear/deathYear, marriage years fromPersonRelationship.fromYear). Trusted, free, no extra entry. - 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.
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();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.
- Backend:
TimelineEvententity + migration — entity,EventType, Flyway migration + join tables, repository. (foundation) - Backend: TimelineEvent CRUD API —
TimelineEventController+TimelineServicewrite methods,TimelineEventRequestDTO, permission gating,GET /events/{id}. - Backend: derived person-events — assemble Geburt/Tod/Heirat from 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.