From 57aeb1ec7b017f8e5c6e99cd63a4108ea4027d7a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 19:18:55 +0200 Subject: [PATCH 1/8] 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 --- .../2026-06-07-family-timeline-design.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-family-timeline-design.md diff --git a/docs/superpowers/specs/2026-06-07-family-timeline-design.md b/docs/superpowers/specs/2026-06-07-family-timeline-design.md new file mode 100644 index 00000000..3858ce5a --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-family-timeline-design.md @@ -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 ``. +- 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 `` 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. -- 2.49.1 From ebe4198be95340037fe14c02c2a6b98606b689df Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 7 Jun 2026 19:26:13 +0200 Subject: [PATCH 2/8] 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 --- .../2026-06-07-family-timeline-design.md | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-family-timeline-design.md b/docs/superpowers/specs/2026-06-07-family-timeline-design.md index 3858ce5a..c0316093 100644 --- a/docs/superpowers/specs/2026-06-07-family-timeline-design.md +++ b/docs/superpowers/specs/2026-06-07-family-timeline-design.md @@ -11,7 +11,7 @@ The archive can capture, transcribe, organize, and browse letters, but the trans 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. +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. @@ -81,14 +81,35 @@ Mirrors the `Document` date model for consistency, so events and letters use one `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 existing curated data; never stored: +Assembled on read from the migrated `Person` data; never stored: | Source | Derived event | `eventDate` | precision | |---|---|---|---| -| `Person.birthYear` | *Geburt: {name}* | `{birthYear}-01-01` | `YEAR` | -| `Person.deathYear` | *Tod: {name}* | `{deathYear}-01-01` | `YEAR` | +| `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). @@ -144,17 +165,18 @@ Input DTO `TimelineEventRequest` lives flat in the `timeline/` package. Errors u ## Proposed issue breakdown (milestone "Zeitstrahl / Family Timeline") -Ordered so each issue is independently shippable and reviewable; later issues depend on earlier ones. +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. **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 `` on Person detail. -10. **Polish & a11y** — mobile layout at 375px, dark mode, axe checks, i18n completeness (de/en/es). +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 API** — `TimelineEventController` + `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 endpoint** — `GET /api/timeline` merging events + derived events + letters into `TimelineDTO`; year-bucketing, precision sort, undated bucket, filters. +6. **Frontend: shared date-label helper + types** — `dateLabel.ts`, regen API types. +7. **Frontend: global `/zeitstrahl` view** — `TimelineView`, `YearBand`, `EventCard`, `LetterCard`, server load. +8. **Frontend: filters** — `TimelineFilters` (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 `` 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. -- 2.49.1 From db081b04885c32c450e29e1c00878eefbe66a45f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 8 Jun 2026 16:27:15 +0200 Subject: [PATCH 3/8] docs(timeline): add Zeitstrahl visual specs (global Concept A, event editor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visual design specs for Milestone #14: - zeitstrahl-global-concepts.html — A/B/C exploration of the global timeline - zeitstrahl-final-spec.html — canonical Concept A (global + per-person Lebensweg) - zeitstrahl-event-editor-spec.html — curator event editor + document quick-action Co-Authored-By: Claude Opus 4.8 --- docs/specs/zeitstrahl-event-editor-spec.html | 418 +++++++ docs/specs/zeitstrahl-final-spec.html | 391 +++++++ docs/specs/zeitstrahl-global-concepts.html | 1061 ++++++++++++++++++ 3 files changed, 1870 insertions(+) create mode 100644 docs/specs/zeitstrahl-event-editor-spec.html create mode 100644 docs/specs/zeitstrahl-final-spec.html create mode 100644 docs/specs/zeitstrahl-global-concepts.html diff --git a/docs/specs/zeitstrahl-event-editor-spec.html b/docs/specs/zeitstrahl-event-editor-spec.html new file mode 100644 index 00000000..6670500a --- /dev/null +++ b/docs/specs/zeitstrahl-event-editor-spec.html @@ -0,0 +1,418 @@ + + + + + +Zeitstrahl — Ereignis-Editor & Brief-Gruppierung · Quick-Action im Dokument · Familienarchiv + + + + +
+ + +
+

Ereignis-Editor & Brief-Gruppierung · Quick-Action im Dokument

+

Wie kuratierte Zeitstrahl-Ereignisse entstehen und wie Briefe gruppiert werden — von zwei Seiten in ein Datenmodell (TimelineEvent.documents): der Ereignis-Editor unter /zeitstrahl/events/[id]/edit (Kurator baut, verlinkt viele Briefe) und die Quick-Action im Dokument-Detail (beim Lesen schnell zuordnen). Beide bauen auf bereits ausgelieferten Komponenten auf.

+
+ Milestone #14 · Zeitstrahl + Reuse: GeschichteEditor · DocumentMultiSelect · PersonMultiSelect + WRITE_ALL +
+ +
+ + + +
+

1 · Zwei Einstiegspunkte, ein Datenmodell

+

Manuelle Gruppierung = ein TimelineEvent mit verknüpften Dokumenten. Kuratoren arbeiten in beide Richtungen — wir bauen beide, statt eine zu erzwingen.

+
+
+ +
+ B · Dokument-zuerst — beim Lesen eines Briefs. Quick-Action im Dokument-Detail: bestehendes Ereignis wählen oder neu anlegen, verlinkt diesen einen Brief. Spiegelt die bestehende Geschichten-Spalte im Details-Drawer (DocumentMetadataDrawer.svelte). +
+
+ + + +
+

2 · Ereignis-Editor — /zeitstrahl/events/[id]/edit

+

Form-Actions-Muster, gegated mit WRITE_ALL. Layout & Verhalten 1:1 vom GeschichteEditor übernommen: Hauptspalte + Sidebar (lg:grid-cols-[2fr_1fr]), Sticky-Save-Bar, beforeNavigate-Warnung bei ungespeicherten Änderungen.

+
+ +
+
+
DokumentePersonenZeitstrahlStammbaumKR
+
+
‹ Zurück zum Zeitstrahl
+
Ereignis bearbeiten
+ +
+ +
+ +
+
Briefe von der Front
+
+ +
+
+
② Typ
+
PersönlichHistorisch
+
+
+
③ Datum · Präzision
+
+
1915
+
Jahr
+
+
Bei „Zeitspanne" erscheint ein zweites End-Datum-Feld. Bei „ca." / „Saison" passt sich nur das Label an.
+
+
+ +
+
④ Beschreibung · optional
+
Karls Feldpost von der Westfront, 1915 — wöchentliche Briefe an Elfriede und den neugeborenen Hans. Eine zusammenhängende Korrespondenz, die hier als Cluster gebündelt wird …
+
Schlichtes Textfeld (kein Rich-Text wie Geschichten) — Ereignisse sind kurze Notizen, keine Langform.
+
+
+ + + +
+ + +
+ Änderungen werden erst beim Speichern übernommen. +
+ Löschen + Abbrechen + Speichern +
+
+
+
+ +
+
Titel — großes Serifen-Feld, wie der Geschichten-Titel. Pflichtfeld (Validierung bei Blur).
+
TypPERSONAL / HISTORICAL Segmented-Control. Steuert Rendering (Mint-Pille vs. Welt-Band).
+
Datum + Präzision — geteilte DatePrecisionInput (gleiche Logik wie Dokument-Datum, metaDatePrecision). „Zeitspanne" blendet End-Datum ein.
+
Beschreibung — optionales Textfeld (TEXT), bewusst schlicht.
+
Verknüpfte Briefehier wird gruppiert. Wiederverwendung von DocumentMultiSelect (Typeahead, Chips, Hidden-Inputs).
+
Beteiligte PersonenPersonMultiSelect. Bestimmt, in welchem „Lebensweg" das Ereignis auftaucht.
+
Sticky-Save-Bar — Speichern primär, Abbrechen sekundär, Löschen nur im Edit-Modus (mit Bestätigung).
+
/new — leeres Formular. Mit ?documentId=… ist Feld ⑤ vorbefüllt (aus der Quick-Action, §4-D).
+
+ + + +
+

3 · Brief-Gruppierung im Editor — der Dokument-Picker

+

Feld ⑤ ist der unveränderte DocumentMultiSelect: Tippen sucht über /api/documents/search?q=… (debounced 300 ms), Treffer mit ehrlichem Datums-Label, bereits gewählte werden gefiltert. Jeder Klick fügt einen Brief zum Cluster.

+
+ +
+
+
Suche aktiv — DropdownDocumentMultiSelect
+
+
+ ✉ Westfront-Brief · Mär 1915 × + ✉ Feldpost Verdun · Jul 1915 × + Verdun▏ +
+
+
Feldpost aus Verdun · Brief · Juli 1915
+
Brief aus dem Verdun-Lazarett · Brief · August 1916
+
Rückkehr aus Verdun · Brief · ca. 1917
+
+
Label = title · formatDocumentDate(precision). Bereits verknüpfte Briefe erscheinen nicht in den Treffern (Dedup).
+
+
+ +
+
Inline „+ Ereignis" am Jahres-BandZeitstrahl
+
+
+
+
1915
+
✉ 24 Briefe
Monats-Dichte ▾
+ +
+
Kuratoren können auch direkt im Zeitstrahl ein Ereignis anlegen — öffnet denselben Editor, Jahr & Briefe des Bandes vorbefüllt.
+
+
+
+ + +
+ + +
+

4 · Quick-Action im Dokument-Detail — wo sie lebt

+

Die Dokument-Detailseite ist ein vollflächiger Viewer ohne Sidebar (fixed inset). Aktions-Flächen gibt es nur zwei: die DocumentTopBar und den aufklappbaren Details-Drawer. Die Quick-Action lebt an beiden — primär als „Zeitstrahl"-Spalte im Drawer (spiegelt die Geschichten-Spalte), plus ein Top-Bar-Button für den Ein-Klick-Weg.

+
+ + + +
+
+ +
+
+
+
+
+
Brief über die Lage an der Westfront
+
März 1915
+
+
KRER
+
+ + Details ▴ +
+ + ✎ Transkribieren + + ⊕ Zeitstrahl + +
+
+ + +
+
+ +
+
Details
+
Datum
März 1915
+
Ort
Westfront
+
Status
Transkribiert
+
+ +
+
Personen
+
KRKarl Raddatz
+
ERElfriede Raddatz
+
+ +
+
Schlagwörter
+
KriegBriefe von der Front
+
+ +
+
+ Zeitstrahl + + Zuordnen +
+ +
+
Briefe von der Front×
+
1915 · 24 Briefe · persönlich
+ Krieg +
+ +
+ Ereignis suchen … + + Neu +
+
+
+
+
↓ PDF-Viewer (Brief-Scan) …
+
+ +
+
ATop-Bar-Button „⊕ Zeitstrahl" — Mint-Akzent im Aktions-Cluster (DocumentTopBarActions). Öffnet ein kleines Popover zum Ein-Klick-Zuordnen, ohne den Drawer zu öffnen. Im Mobile-Menü als Eintrag.
+
B„Zeitstrahl"-Spalte im Details-Drawer — neue Spalte neben Geschichten. Zeigt verknüpfte Ereignisse (Titel · Datum · Tag-Chip), Unlink über ×, plus Quick-Add-Zeile. Nur sichtbar/aktiv bei canWrite.
+
CQuick-Add-Zeile — Typeahead „Ereignis suchen …" (sofortiges Verlinken, keine Navigation) + „+ Neu".
+
D„+ Neu"/zeitstrahl/events/new?documentId={id} — öffnet den Editor (§2) mit diesem Brief in Feld ⑤ vorbefüllt. Spiegelt /geschichten/new?documentId=.
+
+ + + +

5 · Quick-Action — Zustände

Der Typeahead in der Zeitstrahl-Spalte (oder im Top-Bar-Popover). Gleiches Muster wie DocumentMultiSelect, nur sucht es Ereignisse statt Dokumente.

+ +
+
+
A · Nicht zugeordnet
+
+
Noch keinem Ereignis zugeordnet.
+
Ereignis suchen …+ Neu
+
+
+
+
B · Suche — Treffer
+
+
Front▏
+
+
Briefe von der Front · 1915 · 24 Briefe
+
Kriegsausbruch · 1914 · 6 Briefe
+
+ „Front" als neues Ereignis anlegen
+
+
+
+
+
C · Zugeordnet
+
+
Briefe von der Front✓ verknüpft ×
Krieg
+
Sofortiges Verlinken (POST). Toast „Zum Ereignis hinzugefügt", aria-live. Unlink über × (DELETE).
+
+
+
+
D · Mehrfach zugeordnet
+
+
+
Briefe von der Front×
+
Weihnachten 1915×
+
+
Ein Brief darf zu mehreren Ereignissen gehören (ManyToMany) — alle werden gelistet.
+
+
+
+ + + +

6 · Wiederverwendete Bausteine & Tokens

+
Maximal wiederverwenden: DocumentMultiSelect (Brief-Gruppierung, unverändert) · PersonMultiSelect (Beteiligte) · GeschichteEditor-Layout (zwei Spalten, Sticky-Save, beforeNavigate) · DocumentMetadataDrawer-Spaltenmuster (Quick-Action) · useUnsavedWarning · formatDocumentDate / DatePrecision. Brand-Tokens wie im Zeitstrahl-Spec: Navy #012851, Mint #a1dcd8, Linie #e4e2d7, ink-3 #6b7280, danger #c0392b; Serifen-Titel (Tinos), Sans-Chrome (Montserrat).
+ + + +

7 · Implementierungs-Referenz & Barrierefreiheit

+
+ + + + + + + + + + + + + +
BausteinDatei / EndpointVerantwortung
Editor-Route (neu)/zeitstrahl/events/new · [id]/edit+page.server.ts (Form-Actions, WRITE_ALL) + +page.svelte; ?documentId= vorbefüllt Feld ⑤
Editor-Komponente (neu)TimelineEventEditor.svelteSpiegelt GeschichteEditor: Titel, Typ, Datum+Präzision, Beschreibung; Sidebar-Picker; Sticky-Save; beforeNavigate
Brief-Gruppierung (reuse)DocumentMultiSelect.svelteUnverändert — Typeahead /api/documents/search, Chips, Hidden-Inputs documentIds
Personen (reuse)PersonMultiSelect.svelteUnverändert — Beteiligte Personen
Datum + PräzisionDatePrecisionInput (geteilt)Wie Dokument-Datum (metaDatePrecision); „Zeitspanne" → End-Datum; formatDocumentDate fürs Label
Quick-Action-Spalte (neu)DocumentTimelineColumn.svelteIm DocumentMetadataDrawer neben Geschichten; verknüpfte Ereignisse + Quick-Add; nur bei canWrite
Quick-Add-Picker (neu)DocumentTimelineEventPicker.svelteEreignis-Typeahead; sofort verlinken oder ?documentId= zum Editor; auch im Top-Bar-Popover
Top-Bar-Button (neu)DocumentTopBarActions · DocumentMobileMenu„⊕ Zeitstrahl"-Button (canWrite); öffnet Quick-Add-Popover
Backend — CRUDPOST · PUT · DELETE /api/timeline/eventsTimelineEventController, WRITE_ALL; TimelineEventRequest mit documentIds / personIds
Backend — Link/UnlinkPUT /api/timeline/events/{id}Verlinken/Lösen läuft über das Event-Update (documents-Set); kein neuer ErrorCode nötig
BarrierefreiheitPicker-Dropdowns Tastatur-navigierbar (↑↓↵), aria-live für „verknüpft/gelöst"; 44px-Ziele; sichtbarer Fokus-Ring; Löschen/Unlink mit Bestätigung
+
+
Offene Designentscheidung: Soll der Top-Bar-Button (A) MVP sein oder reicht zunächst die Drawer-Spalte (B)? Empfehlung: B als MVP (spiegelt Geschichten exakt, geringster Aufwand), A als schneller Nachzug.
+ +
+ + diff --git a/docs/specs/zeitstrahl-final-spec.html b/docs/specs/zeitstrahl-final-spec.html new file mode 100644 index 00000000..301b1404 --- /dev/null +++ b/docs/specs/zeitstrahl-final-spec.html @@ -0,0 +1,391 @@ + + + + + +Globaler Zeitstrahl — Finale Spezifikation (Konzept A) · Milestone #14 · Familienarchiv + + + + +
+ + +
+

Globaler Zeitstrahl — Finale Spezifikation

+

Kanonische Spezifikation für /zeitstrahl auf Basis von Konzept A „Der Lebensfaden": eine durchgehende vertikale Achse, die Personen-Lebensereignisse, kuratierte Ereignisse und Briefe zu einer Erzählung in der Zeit verwebt. Dieselbe Komponente betreibt den globalen Zeitstrahl und den per-Person „Lebensweg". Enthält die vollständige Fall-Abdeckung (leere Jahre, wenige Briefe, hunderte Briefe, undatiert) und die drei Gruppierungs-Modi.

+
+ Milestone #14 · Zeitstrahl + Konzept A — final + Phone-first · honest DatePrecision +
+ +
+ + + +
+

1 · Anatomie von Konzept A

+

Eine Achse, sieben Bausteine. Die Zeit ist die Achse — Lebensereignisse & Jahre als zentrierte Pillen unterbrechen den Faden (Text wird nie von der Linie gekreuzt), Welt-Ereignisse legen sich als Bänder quer, Briefe verdichten sich adaptiv.

+
+ +
+
Lebensereignis-Pille
Geburt * · Tod · Heirat . Abgeleitet aus Person-Daten. Zentriert, gefüllt — unterbricht die Achse. Glyphen aus personLifeDates.ts.
+
Kuratierte Ereignis-Pille
PERSONAL — Umzug, Auswanderung. Mint-Rand. Editierbar im Kurator-Editor.
+
Welt-Band
HISTORICAL — Krieg, Inflation. Gedämpftes Band quer über die Achse als Kontext.
+
Einzel-Brief
Kleiner Punkt + Karte, alternierend links/rechts. Wurzel-Tag-Farbchip. Link zu /documents/[id].
+
Jahres-Strip
Verdichtung dichter Jahre: Anzahl + 12-Monats-Sparkline. MonthBucket / aggregateToYears.
+
Lücke & Ohne-Datum
Ruhige/leere Jahre als dünne Span-Zeile gefaltet; UNKNOWN-Briefe im „Ohne Datum"-Eimer am Ende.
+
+ + + + + +
+

2 · Die drei Gruppierungs-Modi

+

Gleicher Ausschnitt (1914–1915), dreimal gerendert. Nur die losen Briefe ordnen sich um — die Achse bleibt stabil. Schmale Spaltenbreite = Phone-/Lebensweg-Form derselben Komponente.

+
+ +
+ + +
+
Datum Chronologisch
+
Standard. Briefe nach Datum; dichte Jahre verdichten zum Strip. Reine Zeit-Reihung.
+
+
+
1914
+
Heirat: Karl & Elfriede
1914 · abgeleitet
+
◍ Erster Weltkrieg
1914–1918
+
✉ Kriegsausbruch — Brief an die Familie
Karl → Elfriede · 4. Aug 1914
+ +
1915
+
*
Geburt: Hans Raddatz
Sommer 1915 · abgeleitet
+
+
✉ 24 BriefeMonate ▾
+
+
+
+
+
+ + +
+
Ereignis Kuratiert
+
Briefe bündeln unter kuratierte Ereignisse (TimelineEvent.documents). Erzählende Cluster statt Listen.
+
+
+
1914
+
Heirat: Karl & Elfriede
1914 · abgeleitet
+
◍ Erster Weltkrieg
1914–1918
+ +
1915
+
*
Geburt: Hans Raddatz
Sommer 1915 · abgeleitet
+
✉ Briefe von der Front · 24
Karl ⇄ Elfriede & Hans · 1915 ▾
Krieg
+
✉ Weihnachten 1915 · 3
kuratiertes Ereignis ▾
Weihnachten
+
+
+
+ + +
+
Thema Nach Wurzel-Tag
+
Optional (Post-MVP). Lose Briefe je Jahr in Wurzel-Tag-Eimer; Mehrfach-Tags dedupliziert auf den primären.
+
+
+
1914
+
Heirat: Karl & Elfriede
1914 · abgeleitet
+
◍ Erster Weltkrieg
1914–1918
+
Krieg6 ▾
+ +
1915
+
*
Geburt: Hans Raddatz
Sommer 1915 · abgeleitet
+
Krieg › Briefe von der Front24 ▾
+
Weihnachten3 ▾
+
Familie2 ▾
+
+
+
Hinweis im UI: „Brief mit mehreren Tags erscheint unter seinem primären Tag."
+
+ +
+ + + +
+

3 · Vollständige Vorschau — alle Dichte-Fälle

+

Ein durchgehender Zeitstrahl (Desktop, zentrale Achse) von 1899 bis „Ohne Datum". Jeder Dichte-Fall kommt genau einmal vor — von leeren Jahren bis zu hunderten Briefen.

+
+ + + +
+
Zeitstrahl
+
Die Familie Raddatz · 1899–1950 · 412 Briefe · 38 Ereignisse  ·  Gruppierung: Datum
+ +
① Leere Jahre → gefaltet
+
+ + +
1899 – 1908 · keine Einträge
+ + +
1909
+
② Wenige Briefe → einzeln
+
✉ Brief aus Stettin
Elfriede → Karl · Mai 1909
Familie
+
✉ Geburtstagsgruß
Karl → Hans · Sep 1909
+
✉ Brief zum Jahresende
Karl → Elfriede · Dez 1909
Weihnachten
+ + +
1914
+
Heirat: Karl & Elfriede Raddatz1914 · abgeleitet aus Beziehung
+
④ Welt-Band (RANGE 1914–1918)
+
◍ Erster Weltkrieg1914–1918 · historisch · 187 Briefe in dieser Zeit
+ + +
1915
+
③ Hunderte Briefe → Jahres-Strip   ⑤ „Sommer 1915"
+
*Geburt: Hans RaddatzSommer 1915 · abgeleitet · SEASON
+
+
✉ 187 BriefeMonats-Dichte · antippen → Monate → Briefe ▾
+
+
Jan 1915Dez 1915
+
+ + +
1916 – 1922 · Nachkriegsjahre · 96 Briefe
+ + +
⑤ „ca. 1923" → APPROX
+
◍ Hyperinflationca. 1923 · historisch
+ + +
1924
+
Auswanderung nach ArgentinienFrühjahr 1924 · persönlich · kuratiert
+ + +
1925 – 1950 · keine Einträge
+
+ + +
⑥ Undatiert → eigener Eimer am Ende
+
+
Ohne Datum · 11 Briefe
+
Brief ohne JahresangabePräzision UNKNOWN
+
Fragment, Absender unklar+ 9 weitere ▾
+
+
+
Kein erfundenes Datum: undatierte Briefe wandern nie spekulativ in ein Jahr, sondern bleiben sichtbar im Eimer. RANGE-Einträge (Krieg) erscheinen einmal im Start-Jahr mit Spannen-Marker, nicht in jedem überspannten Jahr.
+ + + +

4 · Datums-Präzision (geteilt von Ereignissen & Briefen)

Eine Render-Logik für alle datierten Einträge — dateLabel.ts, gespeist von DatePrecision.

+
+ + + + + + + + + +
DatePrecisionDarstellungBeispielWirkung auf der Achse
DAYvollständiges Datum28. Juli 1914exakte Sortierung im Jahres-Band
MONTHMonat + JahrJuli 1914Monats-Sortierung
SEASONJahreszeit + JahrSommer 1914grobe Reihung
YEARnur Jahr1914ans Band-Ende
APPROX„ca." + Jahrca. 1914mit „ca."-Marker
RANGEStart–Ende1914–1918Start-Jahr, Spannen-Marker, nicht dupliziert
UNKNOWNundatiertOhne Datumeigener Eimer am Ende
+
+ + + +

5 · Responsiv — eine Komponente, drei Breiten

Identisches Markup & identische Daten. Nur die Achs-Position wechselt per Container-Query.

+
+
≥ 1024px · Desktop
Zentrale Achse, Briefe alternierend links/rechts, Welt-Bänder über volle Breite. Pillen unterbrechen die Linie.
+
< 1024px · Phone
Linke Achse, alles einseitig rechts. DOM-Reihenfolge bleibt streng chronologisch (<ol>) — Screenreader liest linear.
+
Lebensweg-Rail · 35%
Gleiche linke Achse in der Personenseite (<TimelineView personId>), gefiltert auf eine Person. Rail-Tauglichkeit = Stärke von A.
+
+ + + +

6 · Design-Tokens (echte, ausgelieferte Werte)

Aus frontend/src/routes/layout.css. Keine Hardcodes in der Komponente.

+
+ + + + + + + + + + +
RolleTokenWertEinsatz
Achse / Knoten / Headerbrand-navy#012851Spine, Lebensereignis-Pillen, Jahres-Badges, Titel
Akzent / Brief-Punktbrand-mint#a1dcd8Brief-Punkte, kuratierte Pillen-Ränder, Sparkline, Dark-Mode-Auswahl
Historisch / Welttag-slate#607080Welt-Bänder & Glyphe ◍ — gedämpft
Tag-Chip-Farben--c-tag-* (Wurzel)sage · sienna · amber · violet — Farbe vom Wurzel-Tag, Punkt + Label
Seite / Karte / Liniecanvas · surface · line#f0efe9 · #ffffff · #e4e2d7
Text sekundärtext-ink-3#6b7280Meta-Zeilen (4,8:1 auf weiß — AA ✓)
Schriftfont-serif · font-sansTinos · MontserratNamen/Titel serif · Labels/Chrome sans
Lebensdaten-GlyphenpersonLifeDates.ts* † ⚭Geburt · Tod · Heirat — konsistent mit Personenkarten
+
+ + + +

7 · Implementierungs-Referenz & Barrierefreiheit

Domain-Ordner frontend/src/lib/timeline/; Route /zeitstrahl; Backend GET /api/timeline.

+
+ + + + + + + + + + + + +
BausteinKomponente / DateiVerantwortung
OrchestratorTimelineView.svelteLädt /api/timeline; optionaler personId für globalen vs. Lebensweg-Modus; hält den Gruppierungs-Modus
Jahres-BandYearBand.svelteJahres-Badge + Einträge; Lücken-Faltung ruhiger Spannen
Ereignis-PilleEventCard.sveltePERSONAL / HISTORICAL / abgeleitet; zentrierte Pille bzw. Welt-Band; präzisions-bewusstes Label
Brief-KarteLetterCard.svelteEinzel-Brief, alternierende Seite, Wurzel-Tag-Chip, Link /documents/[id]
Jahres-StripYearLetterStrip.svelteAdaptive Verdichtung ab Schwellwert; 12-Monats-Sparkline aus MonthBucket / aggregateToYears (lib/document/timeline.ts)
Datums-HelferdateLabel.tsDatePrecision → deutsches Label; geteilt von Ereignissen & Briefen
Kurator-Editor/zeitstrahl/events/new · [id]/editEreignis anlegen/bearbeiten; Personen- + Dokument-Mehrfach-Picker (Bulk-Linking); WRITE_ALL
Quick-AddDocumentTimelineEventPicker.svelteAuf /documents/[id]: Ereignis wählen/neu anlegen; verlinkt einen Brief
Daten-APIGET /api/timelineVerschmilzt kuratierte + abgeleitete Ereignisse + Briefe in TimelineDTO (Jahres-Eimer + Ohne-Datum); Filter personId · type · fromYear · toYear
BarrierefreiheitAchse = <ol>, chronologische DOM-Reihenfolge; ◍ ✉ * nie nur Farbe — Glyphe + Label; 44px-Tap-Ziele; prefers-reduced-motion; axe in Light & Dark
+
+ +
+ + diff --git a/docs/specs/zeitstrahl-global-concepts.html b/docs/specs/zeitstrahl-global-concepts.html new file mode 100644 index 00000000..7bb0ac78 --- /dev/null +++ b/docs/specs/zeitstrahl-global-concepts.html @@ -0,0 +1,1061 @@ + + + + + +Globaler Zeitstrahl — Visuelle Konzept-Exploration · Milestone #14 · Familienarchiv + + + + +
+ + +
+

Globaler Zeitstrahl — Visuelle Konzept-Exploration

+

Der „Zeitstrahl" unter /zeitstrahl ist die globale Familien-Chronik. Diese Exploration beantwortet zuerst die entscheidende Frage — warum sollte jemand den Zeitstrahl benutzen statt einfach durch die sortierte Suchliste zu scrollen? — und zeigt dann drei Layout-Konzepte, die genau das tun, was die Suche nicht kann: drei Ebenen zu einer Erzählung in der Zeit verweben.

+
+ Milestone #14 · Zeitstrahl + Phone-first · 375px + Explorativ — vor Implementierung +
+ +
+ + + +
+

1 · Warum Zeitstrahl ≠ Suchliste

+

Die Gefahr: zwei Seiten, die wie dasselbe wirken. Wenn der Zeitstrahl nur „Briefe, chronologisch" zeigt, fragt jeder zu Recht — wozu? Der Unterschied ist nicht die Sortierung. Es sind die drei Ebenen und der historische Kontext, die nur hier zusammenkommen.

+
+ +
+ +
+
/documents · nach Datum sortiertEINE EBENE
+
+
Brief über die Lage an der Westfront
Karl → Elfriede Raddatz
Mär 1915
+
Feldpost aus Verdun
Hans → Karl Raddatz
Jul 1915
+
Brief an die Eltern
Karl → Elfriede Raddatz
Sep 1915
+
Päckchen-Dank
Karl → Elfriede Raddatz
Nov 1915
+
Nur Briefe. Keine Geburten, keine Hochzeiten, kein Krieg. Warum 1915 so viele Feldpostbriefe? Die Liste sagt es nicht — man muss es wissen.
+
+
+ +
vs
+ + +
+
/zeitstrahl · die Familie in der ZeitDREI EBENEN
+
+
+
1914
+
⚭ Heirat: Karl & Elfriede Raddatz · abgeleitet
+
Erster Weltkrieg
1914–1918 · historisch
+
1915
+
4 Briefe · Feldpost von der Westfront
+
* Geburt: Hans Raddatz · abgeleitet
+
1918
+
Kriegsende
Nov 1918 · historisch
+
+
+
+
+ +
Der Aha-Moment: Plötzlich ergeben die vier Feldpostbriefe von 1915 Sinn — sie liegen zwischen Hochzeit und Kriegsende, neben der Geburt des Sohnes. Die Suche findet Dokumente. Der Zeitstrahl erzählt, was mit der Familie geschah. Das ist kein sortierter Listenfilter — es ist eine Erzählung, die die Suche strukturell nicht leisten kann.
+ + + + + +
+

2 · Die drei Ebenen — visuelle Sprache

+

Jede Ebene muss auf den ersten Blick unterscheidbar sein. Diese Kodierung gilt in allen drei Konzepten gleich — nur das Layout ändert sich, nicht die Bedeutung der Farben & Glyphen.

+
+ +
+
+
+
Abgeleitetes Lebensereignis
Geburt * · Tod · Heirat — automatisch aus Person.birthDate / deathDate / SPOUSE_OF.fromYear. Große Navy-Knoten auf der Achse. Glyphen * / aus personLifeDates.ts. Badge „abgeleitet", nicht im Zeitstrahl editierbar.
+
+
+
+
Kuratiertes persönliches Ereignis
EventType.PERSONAL — von der Familie geschrieben (Umzug, Krankheit, Auswanderung). Mint-Akzent, Serifen-Titel, optionaler Beschreibungstext. Editierbar über /zeitstrahl/events/[id]/edit.
+
+
+
+
Historisches Ereignis (Welt)
EventType.HISTORICAL — Krieg, Inflation. Gedämpftes Slate-Band quer über die Achse als Kontext-Hintergrund. Bewusst zurückhaltend, damit Familie im Vordergrund bleibt.
+
+
+
+
Brief
Platziert nach Document.documentDate. Kleine Mint-Punkte, kompakte Karte (Absender → Empfänger), verlinkt zu /documents/[id]. Häufungen clustern unter dem Ereignis, zu dem sie gehören.
+
+
+ +
+ + + + + + + + + +
DatePrecisionDeutsche DarstellungBeispielWirkung auf der Achse
DAYvollständiges Datum28. Juli 1914sortiert exakt innerhalb des Jahres-Bandes
MONTHMonat + JahrJuli 1914sortiert nach Monat innerhalb des Bandes
SEASONJahreszeit + JahrSommer 1914grobe Reihung im Band
YEARnur Jahr1914ans Ende des Jahres-Bandes
APPROX„ca." + Jahrca. 1914mit „ca."-Marker, Jahres-Band
RANGEStart–Ende1914–1918im Start-Jahr mit Spannen-Marker, nicht dupliziert
UNKNOWNundatiertOhne Datumeigener Eimer ganz am Ende
+
+ +
+ + +
+
+
A
+
+
Konzept A · Erzählend
+
Der Lebensfaden — eine durchgehende Achse
+
Eine einzige vertikale Spine läuft von oben nach unten durch alle Jahre. Lebensereignisse sitzen als große Knoten auf dem Faden, historische Ereignisse legen sich als Bänder dahinter, Briefe zweigen als kleine Punkte ab. Liest sich wie eine Familien-Saga, von 1899 bis 1950 am Stück scrollbar.
+
DifferenzierungMaximaler Abstand zur Suche — gar keine Listen-Anmutung. Der Faden ist eine Geschichte, kein Suchergebnis.
+
Trade-off: bei dichten Brief-Jahren kann der Faden lang werden — braucht eine Quiet-Year-Kompression (mehrere ereignislose Jahre als ein dünner Abschnitt).
+
+
+ +
+
+
+
+
+
+
Zeitstrahl
+
Die Familie Raddatz · 1899–1950 · 412 Briefe, 38 Ereignisse
+ + +
+
+ + +
+
*
+
Geburt: Karl Raddatz
+
14. März 1901 · abgeleitet
+
+ + +
+
+
Heirat: Karl & Elfriede
+
1914 · abgeleitet
+
+ + +
+
◍ Erster Weltkrieg
+
1914–1918 · historisch
+
+ + +
+
+
1915
+
+
✉ Brief über die Westfront
+
Karl → Elfriede · Mär 1915
+
Krieg › Briefe von der Front
+
+
+
✉ Feldpost aus Verdun
+
Hans → Karl · Jul 1915 · +2 weitere
+
Krieg › Briefe von der Front
+
+
+ + +
+
◍ Hyperinflation
+
1923 · historisch
+
+ + +
+
+
Auswanderung nach Argentinien
+
Frühjahr 1924 · persönlich
+
+ +
+
+
+
+ +
+

Warum dieses Layout

+
    +
  • Ein Faden, keine Karten-Liste. Schon die Silhouette signalisiert „Geschichte", nicht „Suchtreffer".
  • +
  • Hierarchie über Knotengröße: Lebensereignisse 16px-Knoten, kuratierte 11px, Briefe 7px-Punkte. Das Auge liest zuerst die Meilensteine.
  • +
  • Welt-Bänder hinterlegt in gedämpftem Slate — präsent, aber nie konkurrierend mit der Familie.
  • +
  • Brief-Cluster fassen Häufungen zusammen („+2 weitere") statt 24 Zeilen zu zeigen — genau hier unterscheidet es sich von der Liste.
  • +
+

Offene Fragen

+
    +
  • Quiet-Year-Kompression: ereignislose Jahre als dünner „1925–1929"-Abschnitt zusammenfalten?
  • +
  • Tap auf Cluster → expandiert inline oder springt in die gefilterte Suche?
  • +
+
+
+
+ +
+ + +
+
+
B
+
+
Konzept B · Strukturiert
+
Jahres-Bänder — drei Spuren pro Jahr
+
Jedes Jahr ist ein volle-Breite-Abschnitt (wie die bestehende ChronikTimeline). Innerhalb des Bandes machen drei Spuren — Personen · Welt · Briefe — das Verweben explizit und scannbar. Auf dem Desktop nebeneinander, auf dem Phone gestapelt. Sehr lesbar, senioren-freundlich.
+
DifferenzierungDie drei Spuren zeigen die drei Ebenen als Struktur — man sieht sofort, dass hier mehr als Briefe leben.
+
Trade-off: bändriger, weniger „flüssige Saga" als A. Leere Spuren in ruhigen Jahren brauchen eine elegante Leer-Behandlung.
+
+
+ +
+
+
+
+
+
+
Zeitstrahl
+
Die Familie Raddatz · 1899–1950
+ + +
+
+ 1914 + 2 EREIGNISSE · 6 BRIEFE +
+ +
+
Personen
+
Heirat: Karl & Elfriede Raddatz
+
+ +
+
Welt
+
Erster Weltkrieg beginnt · 28. Juli 1914
+
+ +
+
Briefe · 6
+
+
+
+
+ Kriegsausbruch — Brief an die Familie → +
+
+
+ + +
+
+ 1915 + 1 EREIGNIS · 24 BRIEFE +
+
+
Personen
+
*Geburt: Hans Raddatz
+
+
+
Briefe · 24 Feldpost
+
✉ Brief über die Lage an der Westfront
Karl → Elfriede · März 1915 · 23 weitere ▾
Krieg › Briefe von der Front
+
+
+ + +
+ 1916 – 1918ruhige Jahre · 9 Briefe ▾ +
+
+
+
+ +
+

Warum dieses Layout

+
    +
  • Drei benannte Spuren machen das Versprechen explizit — „hier sind Personen, Welt und Briefe", nicht nur Dokumente.
  • +
  • Baut auf vorhandenem Idiom: Jahres-Band = ChronikTimeline-Sektion mit Navy-Header. Schnell umsetzbar, vertraut.
  • +
  • Senioren-stark: klare Boxen, große Jahreszahl, ruhiges Raster — kein überlagertes Spine-Lesen nötig.
  • +
  • Ruhige Jahre kollabieren zu einer dünnen Zeile — hält das Scrollen kurz.
  • +
+

Desktop-Enhancement

+
    +
  • Die drei Spuren werden drei Spalten nebeneinander (Personen | Welt | Briefe) statt gestapelt — das Verweben wird horizontal lesbar.
  • +
+
+
+
+ +
+ + +
+
+
C
+
+
Konzept C · Navigierbar
+
Spine + Dichte-Schiene — 50 Jahre im Griff
+
Eine dünne vertikale Dichte-Schiene am linken Rand (dieselbe Balken-Sprache wie der Such-Dichtefilter, nur hochkant) zeigt Brief-Volumen pro Jahr plus Ereignis-Markierungen — und dient zugleich als Mini-Map & Sprung-Navigation. Rechts die verwobene Erzählung. Verbindet Vertrautheit mit dem Dichtefilter und ist doch klar eine andere Oberfläche.
+
DifferenzierungÜber 50 Jahre at-a-glance navigierbar — etwas, das eine paginierte Suchliste nie bietet. Dichte wird zur Landkarte.
+
Trade-off: Die Schiene kostet horizontale Breite — auf 320px muss sie sehr schmal (oder ausklappbar) sein. Nähe zum Dichtefilter könnte verwirren, wenn nicht klar getrennt.
+
+
+ +
+
+
+
+
+
+ +
+
DICHTE
+ +
+
1900
+
+
+
+
1915
+
+
+
+
1923
+
+
+
1950
+
+
+ + +
+
Zeitstrahl
+
Familie Raddatz · 1915 ▾
+ +
+
+ +
+
*
+
Geburt: Hans Raddatz
+
Sommer 1915 · abgeleitet
+
+ +
+
◍ Erster Weltkrieg
+
1914–1918
+
+ +
+
+
✉ Brief über die Westfront
Karl → Elfriede · Mär 1915
+
✉ Feldpost aus Verdun
Hans → Karl · Jul 1915 · +22 ▾
+
+
+
+
+
+
+ +
+

Warum dieses Layout

+
    +
  • Dichte-Schiene als Mini-Map: 50 Jahre Brief-Volumen auf einen Blick, Tap springt zum Jahr. Das kann keine Suchliste.
  • +
  • Ereignis-Marker (weiß = Lebensereignis, slate = Welt) auf der Schiene zeigen, wo etwas passiert ist — nicht nur wo Briefe sind.
  • +
  • Vertraute Sprache, klar getrennter Kontext: dieselben Balken wie /documents, aber hochkant + als Navigation, nicht als Filter.
  • +
  • Hier in Dark Mode gezeigt — Mint wird zum Auswahl-/Akzentton, wie im Dichtefilter-Spec.
  • +
+

320px-Verhalten

+
    +
  • Schiene schrumpft auf ~24px (nur Balken + Jahrzehnt-Ticks) oder klappt hinter ein „Karte"-Icon.
  • +
+
+
+
+ +
+ + + + +
+

3 · Desktop-Ansichten — A vs. B im direkten Vergleich

+

Auf /zeitstrahl gibt es Breite, die das Phone nicht hat. Hier entscheidet sich A gegen B: A nutzt die Breite für eine dramatische, alternierende Erzähl-Achse; B nutzt sie, um die drei Ebenen als drei echte Spalten nebeneinander lesbar zu machen.

+
+ + +
+ A +
+
Konzept A · Desktop
+
Zentrale Achse, alternierende Ereignisse
+
+
+ +
+
+
DokumentePersonenZeitstrahlStammbaumKR
+
+
Zeitstrahl
+
Die Familie Raddatz · 1899–1950 · 412 Briefe · 38 Ereignisse
+ + +
+ +
+ + +
1914
+ + +
+ + + Heirat: Karl & Elfriede Raddatz1914 · abgeleitet aus Beziehung + +
+ + +
+ ◍ Erster Weltkrieg + 1914–1918 · historisch · 187 Briefe in dieser Zeit +
+ + +
1915
+ + +
+
+
✉ 24 Feldpost — Westfront
Karl ⇄ Elfriede & Hans Raddatz · März–Dez 1915
Krieg › Briefe von der Front
+
+
+
+
+ + +
+ + * + Geburt: Hans RaddatzSommer 1915 · abgeleitet + +
+ + +
+
+
+
+
★ Umzug nach Berlin
persönlich · Herbst 1915 · „Die Wohnung in der Kastanienallee …"
+
+
+ +
+
+
+ +
+
+

Was die Breite hier gewinnt

+
    +
  • Alternierende Seiten — Briefe links, kuratierte Ereignisse rechts; das Auge zickzackt die Geschichte entlang. Maximal un-listenhaft.
  • +
  • Lebensereignisse & Jahre als zentrierte Meilenstein-Pillen, deren gefüllter Hintergrund die Achse unterbricht — der Spine läuft nie durch den Text, sondern endet sauber an der Pille.
  • +
  • Welt-Bänder über die volle Breite — der historische Kontext umschließt buchstäblich die Briefe darunter.
  • +
+
+
+

Risiken für Senioren / A11y

+
    +
  • Alternierende Lese-Richtung ist anstrengender. Mitigation: nie wichtige Info nur rechtsbündig; auf <1024px kollabiert A in die einseitige Phone-Achse.
  • +
  • DOM-Reihenfolge bleibt streng chronologisch (eine <ol>) — Screenreader liest die Saga linear, egal welche Seite.
  • +
+
+
+ + +
+ B +
+
Konzept B · Desktop
+
Drei Spalten — Personen · Welt · Briefe
+
+
+ +
+
+
DokumentePersonenZeitstrahlStammbaumKR
+
+
Zeitstrahl
+
Die Familie Raddatz · 1899–1950
+ + +
+
+
Personen & Familie
+
Weltgeschehen
+
Briefe
+
+ + +
+
1914
+
+
Heirat: Karl & Elfriede
+
+
+
Erster Weltkrieg beginnt
28. Juli 1914
+
+
+
+
6 Briefe · „Kriegsausbruch — Brief an die Familie" →
+
+
+ + +
+
1915
+
+
*Geburt: Hans Raddatz
+
+
+ — im Krieg — +
+
+
✉ Brief über die Westfront
Karl → Elfriede · Mär 1915 · 23 weitere ▾
Krieg
+
+
+ + +
+
1923
+
+
+
Hyperinflation
ca. 1923
+
+
3 Briefe
+
+ +
+
+ +
+
+

Was die Breite hier gewinnt

+
    +
  • Drei Spalten = drei Ebenen, dauerhaft sichtbar. Das Verweben wird zur lesbaren Tabelle: Auge wandert pro Jahr quer durch Familie → Welt → Briefe.
  • +
  • Gemeinsame Jahres-Achse links bindet die Spalten — man sieht sofort „1915: Geburt, Krieg läuft, 24 Feldpost".
  • +
  • Leere Zellen erzählen mit („— im Krieg —") statt zu verschwinden.
  • +
+
+
+

Risiken / A11y

+
    +
  • Tabellarischer, weniger „Saga". Dafür senioren-stärker: ruhige Spalten, keine alternierende Lese-Richtung.
  • +
  • <1024px: die drei Spalten stapeln zu den drei Spuren aus dem Phone-Konzept (siehe §B oben) — ein Markup, zwei Layouts via Container-Query.
  • +
+
+
+ + + +
+

4 · Wiederverwendung: „Lebensweg" in der Personenansicht

+

Dieselbe TimelineView-Komponente mit personId-Prop, gefiltert auf eine Person. Sie zieht in die linke 35%-Spalte der Personenseite ein, direkt unter die PersonCard (persons/[id]/+page.svelte:60–67). Weil diese Spalte schmal ist, ist es exakt das Phone-Layout in Spaltenbreite — kein zweites Design nötig.

+
+ + + + +
+
+
DokumentePersonenZeitstrahlStammbaumKR
+
+
‹ Zurück
+ +
+ +
+ +
+
KR
+
Karl Raddatz
+
* 14. März 1901  †  1967
+
Großvater128 Briefe
+
+ + +
+
Lebensweg nur Karl
+ +
+
+ +
+
*
+
Geburt
+
14. März 1901 · Stettin
+
+ +
+
+
Heirat mit Elfriede
+
1914 · abgeleitet
+
+ +
+
◍ Erster Weltkrieg
+
1914–1918
+
+ +
+
+
1915 · 24 Feldpost
+
an Elfriede & Hans · Westfront
+
+ +
+
+
Tod
+
1967 · abgeleitet
+
+
+
+
+ + +
+
+
Gemeinsame Korrespondenten
+
+
+
Beziehungen
+
Gesendete Dokumente
+
+
+
+
+

Der Lebensweg sitzt unter der PersonCard in der 35%-Spalte. Gefiltert auf Karl: eigene Geburt/Heirat/Tod, beteiligte Welt-Ereignisse als Kontext, eigene Brief-Cluster. <TimelineView personId={person.id} />

+ + +

… und so verhalten sich A vs. B in der schmalen Lebensweg-Spalte

Bei Spaltenbreite (~330px) ist die Entscheidung wichtig: A bleibt eine klare Faden-Achse, B muss seine drei Spuren stapeln.

+
+
+
A · Faden im Rail
+
+
+
+
*
Geburt · 1901
+
Heirat · 1914
+
1915 · 24 Feldpost
+
Tod · 1967
+
+
+
Faden bleibt intakt — eine durchgehende Lebenslinie. Natürliche Passform fürs Rail.
+
+ +
+
B · Bänder im Rail (gestapelt)
+
+
+
1914
+
PERSONEN
⚭ Heirat mit Elfriede
+
WELT
◍ Erster Weltkrieg
+
BRIEFE
6 Briefe ▾
+
+
+
1915
+
PERSONEN
* Geburt: Hans
+
BRIEFE
24 Feldpost ▾
+
+
+
Spuren stapeln zu Mini-Sektionen pro Jahr. Mehr Struktur, aber höher & mehr Scroll im engen Rail.
+
+ +
+

Konsequenz für die Wahl

+
    +
  • A skaliert nahtlos von Rail (330px) über Phone bis Desktop — ein Layout-Idiom, drei Breiten.
  • +
  • B braucht zwei Modi: gestapelt im Rail/Phone, Spalten auf Desktop. Mehr Code, aber explizitere Ebenen-Trennung.
  • +
  • Da der Lebensweg denselben Component nutzt, zählt die Rail-Tauglichkeit doppelt — sie ist ein echtes Argument für A.
  • +
+
+
+ +
+ + +
+

5 · Brief-Gruppierung & Tag-Farben

+

Die Zeit ist die Achse — nicht das Thema. Briefe gruppieren wir nach Datum (Standard), echte Bündel entstehen über kuratierte Ereignisse, und Tags dienen als Farb-Akzent & Filter. So bleibt der Zeitstrahl eine Zeit-Oberfläche und konkurriert nicht mit der /themen-Seite.

+
+ + + +
+ + +
+
① Gruppierungs-Umschalter
+
+ Datum + Ereignis + Thema +
+
Standard = Datum. „Ereignis" bündelt unter kuratierte Ereignisse, „Thema" (optional, Post-MVP) nach Wurzel-Tag.
+ +
② Cluster-Karte (Ereignis)
+
+
+ + Briefe von der Front + Krieg + 24 Briefe + +
+
+
Brief über die Lage an der WestfrontMär 1915
+
Feldpost aus VerdunJul 1915
+
+ 22 weitere · alle öffnen →
+
+
+
Ein Jahr mit 24 Feldpostbriefen wird zu einer erzählenden Zeile statt 24 Treffern — der schärfste Unterschied zur Suche. Quelle: TimelineEvent.documents.
+
+ + +
+
③ Tag-Farb-Chips am Brief
+
Die Farbe kommt vom Wurzel-Tag (Tag.color ist nur auf Root gesetzt). Kinder erben sie: Lazarett & Briefe von der Front tragen die Farbe von Krieg. Nie Farbe allein — immer Punkt + Label.
+ +
+
+
✉ Weihnachtsgruß an Elfriede
+
Karl → Elfriede · Dez 1915
+ Weihnachten › Weihnachtsgrüße +
+
+
✉ Brief aus dem Lazarett
+
Hans → Karl · Aug 1916
+ Krieg › Lazarett +
+
+
✉ Brief zum neuen Jahr
+
Elfriede → Karl · Jan 1916 · 2 Tags
+ Weihnachten + Familie +
+
+
Mehrere Tags → mehrere Chips. Unter „Thema"-Gruppierung erscheint der Brief nur unter seinem primären Tag (dedupliziert, mit Hinweis).
+
+
+ + +
④ Lose Briefe ohne Cluster — adaptive Verdichtung
+ + +
+ +
+
Ruhiges Jahr · ≤ 3 lose Briefe → einzeln
+
+
1909
+
✉ Brief aus Stettin
Elfriede → Karl · Mai 1909
+
✉ Geburtstagsgruß
Karl → Hans · Sep 1909
+
+
Unter dem Schwellwert: jeder Brief als eigene Karte mit Tag-Chip.
+
+ +
+
Dichtes Jahr · > 3 → Jahres-Strip mit Sparkline
+
+
1915
+
+
+ ✉ 24 Briefe + Monats-Dichte ▾ +
+
+
+
+
JanDez
+
+
+
Über dem Schwellwert: ein Strip mit 12-Monats-Sparkline. Tap → Monate → einzelne Briefe, oder „im Suchergebnis öffnen →".
+
+
+ +
Disclosure-Leiter: Jahres-Strip → Monats-Gruppen → einzelne Briefe. Auf jeder Stufe gilt der Schwellwert; die unterste Stufe verlinkt in die gefilterte Dokumentsuche (/documents?from=1915-01-01&to=1915-12-31) — die Brücke zur dritten Oberfläche, dem Dichtefilter. So bleibt der Zeitstrahl Erzählung und delegiert die vollständige Liste an die Suche, die genau dafür gebaut ist.
+ + +
⑤ Wie Cluster entstehen — Kuratierung von zwei Seiten
+ + +
+ +
+
A · Kurator-Editor · /zeitstrahl/events/[id]/edit
+
+
TITEL
+
Briefe von der Front
+
+
TYP
PersönlichHistorisch
+
DATUM · PRÄZISION
1915 · Jahr ▾
+
+
VERKNÜPFTE BRIEFE · 24
+
+ ✉ Westfront-Brief × + ✉ Verdun × + + Brief suchen… +
+
+
Form-Actions-Muster, WRITE_ALL. Dokument-Mehrfach-Picker = Bulk-Linking. Auch inline „+ Ereignis" auf jedem Jahres-Band.
+
+ + +
+
B · Quick-Add · /documents/[id]
+
+
… Dokument-Detail · Seitenleiste …
+
+
Zeitstrahl-Ereignis
+
Briefe von der Front✓ verknüpft
+
Ereignis wählen ▾+ Neues Ereignis
+
+
+
Beim Lesen eines Briefs: bestehendes Ereignis wählen oder direkt neu anlegen — verlinkt diesen einen Brief. Schreibt dieselbe TimelineEvent.documents-Relation.
+
+
+ + + +
+ + +

6 · Empfehlung & nächster Schritt

+
+

Konzept A als Basis, mit der Spur-Klarheit von B und der Schiene von C als Stufe 2.

+

A (Lebensfaden) trifft die Differenzierungs-Frage am direktesten: es sieht null wie eine Suchliste aus und macht die verwobene Erzählung zur Hauptfigur. Die drei benannten Spuren aus B sind die beste Antwort, falls Nutzer-Tests zeigen, dass „welche Ebene ist das?" unklar bleibt — sie lassen sich als Desktop-Enhancement in A einziehen. Die Dichte-Schiene aus C ist die richtige Lösung für „50 Jahre navigieren", aber Stufe 2 — sie löst Navigation, nicht Differenzierung, und kann nachgerüstet werden.

+

Für alle gilt: Brief-Cluster statt Brief-Listen sind der schärfste Unterschied zur Suche — ein Jahr mit 24 Feldpostbriefen wird zu einer erzählenden Zeile („24 Feldpost, Westfront"), nicht zu 24 Treffern. Genau hier hört der Zeitstrahl auf, eine sortierte Suche zu sein. Nächster Schritt: dieses Konzept für die Person-Detail-„Lebensweg"-Ansicht (gefilterte Variante) durchdeklinieren.

+
+ + + +

7 · Design-Tokens (echte, ausgelieferte Werte)

Direkt aus frontend/src/routes/layout.css. Keine Hardcodes in den Komponenten — nur diese Tokens.

+
+ + + + + + + + + + + + + + + +
RolleToken / UtilityWertEinsatz im Zeitstrahl
Spine / Knoten / Headerbrand-navy · --palette-navy#012851Achsen-Spine, Lebensereignis-Knoten, Jahres-Header, Titel
Akzent / Brief-Punktbrand-mint · --palette-mint#a1dcd8Brief-Punkte & -Ringe, kuratierte Ereignis-Akzente, Dark-Mode-Auswahl
Historisch / Welttag-slate (Vorschlag)#607080Welt-Ereignis-Bänder & Glyphe ◍ — gedämpft, im Hintergrund
Tag-Chip-Farben--c-tag-* (nur Wurzel-Tag)sage · sienna · amber · violet · slate — Farbe vom Wurzel-Tag, Kinder erben; Punkt + Label (nie Farbe allein)
Seiten-Hintergrundbg-canvas · --c-canvas#f0efe9Zeitstrahl-Seitenfläche
Karten-Flächebg-surface · --c-surface#ffffffBrief-Karten, Jahres-Bänder
Gedämpfte Flächebg-muted · --c-muted#f5f4efWelt-Spur-Hintergrund (Konzept B)
Linienborder-line · --c-line#e4e2d7Karten-Rahmen, Spur-Trenner
Text primärtext-ink · --c-ink#012851Titel, Namen (= Navy)
Text sekundärtext-ink-3 · --c-ink-3#6b7280Meta-Zeilen (4,8:1 auf weiß — AA ✓)
Schrift — Namen/Titelfont-serif (Tinos)Personennamen, Ereignis- & Jahres-Titel
Schrift — Meta/Chromefont-sans (Montserrat)Labels, Spur-Überschriften, Badges
Lebensdaten-GlyphenpersonLifeDates.ts* † ⚭Geburt * · Tod · Heirat — konsistent mit Personenkarten
+
+ + +

8 · Implementierungs-Referenz

Komponenten-Plan aus der Design-Basis. Neuer Domain-Ordner frontend/src/lib/timeline/; Route /zeitstrahl.

+
+ + + + + + + + + + + + + +
BereichKomponente / DateiVerantwortung
OrchestratorTimelineView.svelteLädt GET /api/timeline; optionaler personId-Prop treibt auch die Lebensweg-Ansicht
Jahres-BandYearBand.svelteEin Jahres-Abschnitt + seine Einträge (Spine-Knoten in A, Spuren-Box in B)
Ereignis-KarteEventCard.sveltePERSONAL / HISTORICAL / abgeleitet, präzisions-bewusstes Datumslabel
Brief-KarteLetterCard.svelteKompakte Zeile Absender → Empfänger, Link zu /documents/[id]; Cluster-Variante; Wurzel-Tag-Farbchip
Lose-Brief-VerdichtungYearLetterStrip.svelteAdaptiver Jahres-Strip mit Monats-Sparkline ab Schwellwert; nutzt MonthBucket / aggregateToYears aus lib/document/timeline.ts
Kurator-Editor/zeitstrahl/events/new · [id]/editEreignis anlegen/bearbeiten; Personen- + Dokument-Mehrfach-Picker (Bulk-Linking); WRITE_ALL, Form-Actions
Quick-Add (Dokument)DocumentTimelineEventPicker.svelteAuf /documents/[id]: bestehendes Ereignis wählen oder neu anlegen; verlinkt einen Brief in TimelineEvent.documents
Datums-HelferdateLabel.tsDatePrecision → deutsches Label; wiederverwendet lib/document/timeline.ts-Helfer wo sinnvoll
Dichte-Schiene (C)TimelineMinimap.svelteStufe 2 — vertikale Dichte + Ereignis-Marker + Sprung-Navigation
Daten-APIGET /api/timelineVerschmilzt Ereignisse + abgeleitete Personen-Ereignisse + Briefe in TimelineDTO (Jahres-Eimer + Ohne-Datum)
A11ySpine = <ol>; Knoten erreichbar; ◍/✉/* nie nur als Farbe — immer Glyphe+Text; 44px-Tap-Ziele; prefers-reduced-motion; axe in Light & Dark
+
+ +
+ + -- 2.49.1 From 7b858e5afdb266ed1db94d22422235f9e62cc6ad Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 09:00:21 +0200 Subject: [PATCH 4/8] feat(geschichten): add i18n keys for document filter chip and empty state Co-Authored-By: Claude Sonnet 4.6 --- frontend/.prettierignore | 1 + frontend/messages/de.json | 3 +++ frontend/messages/en.json | 3 +++ frontend/messages/es.json | 3 +++ 4 files changed, 10 insertions(+) diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 2b6ddd63..02d65194 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -7,6 +7,7 @@ bun.lockb # Miscellaneous /static/ +/src.main/ # Build artifacts /.svelte-kit/ diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 9e008600..961597fe 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1039,6 +1039,9 @@ "geschichten_empty_for_person": "Keine Geschichten für {name} gefunden.", "geschichten_empty_for_persons": "Keine Geschichten für {names} gefunden.", "geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.", + "geschichten_filter_document_chip": "Gefiltert nach Brief:", + "geschichten_filter_remove_document_chip": "Brief {title} aus Filter entfernen", + "geschichten_empty_for_document": "Noch keine Geschichten zu diesem Brief", "geschichten_back_to_index": "Zurück zu Geschichten", "geschichten_published_on": "veröffentlicht am {date}", "journey_compiled_on": "zusammengestellt am {date}", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c837b5cc..7d957e6a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1039,6 +1039,9 @@ "geschichten_empty_for_person": "No stories found for {name}.", "geschichten_empty_for_persons": "No stories found for {names}.", "geschichten_empty_no_filter": "There are no published stories yet.", + "geschichten_filter_document_chip": "Filtered by letter:", + "geschichten_filter_remove_document_chip": "Remove letter {title} from filter", + "geschichten_empty_for_document": "No stories reference this letter yet", "geschichten_back_to_index": "Back to stories", "geschichten_published_on": "published on {date}", "journey_compiled_on": "compiled on {date}", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index b133a089..6ad45fcc 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1039,6 +1039,9 @@ "geschichten_empty_for_person": "No hay historias para {name}.", "geschichten_empty_for_persons": "No hay historias para {names}.", "geschichten_empty_no_filter": "Aún no hay historias publicadas.", + "geschichten_filter_document_chip": "Filtrado por carta:", + "geschichten_filter_remove_document_chip": "Quitar la carta {title} del filtro", + "geschichten_empty_for_document": "Aún no hay historias sobre esta carta", "geschichten_back_to_index": "Volver a Historias", "geschichten_published_on": "publicada el {date}", "journey_compiled_on": "recopilada el {date}", -- 2.49.1 From 03e0dae5aaa59f1a837128368a1cc7a3d6990e90 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 09:05:53 +0200 Subject: [PATCH 5/8] feat(geschichten): resolve document title in loader, return documentFilter object Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/geschichten/+page.server.ts | 22 ++- .../routes/geschichten/page.server.test.ts | 162 +++++++++++++----- 2 files changed, 135 insertions(+), 49 deletions(-) diff --git a/frontend/src/routes/geschichten/+page.server.ts b/frontend/src/routes/geschichten/+page.server.ts index b0550b91..ae4641e6 100644 --- a/frontend/src/routes/geschichten/+page.server.ts +++ b/frontend/src/routes/geschichten/+page.server.ts @@ -14,16 +14,19 @@ export const load: PageServerLoad = async ({ url, fetch }) => { const rawDocumentId = url.searchParams.get('documentId'); const documentId = rawDocumentId && UUID_PATTERN.test(rawDocumentId) ? rawDocumentId : null; - const [listResult, ...personResults] = await Promise.all([ + const [listResult, docResult, ...personResults] = await Promise.all([ api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', personId: personIds.length ? personIds : undefined, - documentId: documentId ?? undefined + documentId: rawDocumentId ?? undefined } } }), + documentId + ? api.GET('/api/documents/{id}', { params: { path: { id: documentId } } }) + : Promise.resolve(null), ...personIds.map((id) => api.GET('/api/persons/{id}', { params: { path: { id } } })) ]); @@ -35,9 +38,22 @@ export const load: PageServerLoad = async ({ url, fetch }) => { .filter((r) => r && r.response.ok && r.data) .map((r) => r!.data!) as Person[]; + let documentFilter: { id: string; title: string | null } | null = null; + if (documentId) { + if (docResult && docResult.response.ok && docResult.data) { + const doc = docResult.data; + documentFilter = { + id: documentId, + title: doc.title || doc.originalFilename || null + }; + } else { + documentFilter = { id: documentId, title: null }; + } + } + return { geschichten: listResult.data ?? [], personFilters, - documentIdFilter: documentId + documentFilter }; }; diff --git a/frontend/src/routes/geschichten/page.server.test.ts b/frontend/src/routes/geschichten/page.server.test.ts index 1a802095..6149c08f 100644 --- a/frontend/src/routes/geschichten/page.server.test.ts +++ b/frontend/src/routes/geschichten/page.server.test.ts @@ -24,17 +24,6 @@ function makeUrl(params: Record = {}) { return url; } -function mockApi() { - const mockGet = vi.fn().mockResolvedValue({ - response: { ok: true, status: 200 }, - data: [] - }); - vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< - typeof createApiClient - >); - return mockGet; -} - function callLoad(url: URL) { return load({ url, @@ -43,10 +32,119 @@ function callLoad(url: URL) { }); } -// ─── documentId filter forwarding ───────────────────────────────────────────── +function mockApi( + opts: { + listData?: unknown[]; + docOk?: boolean; + docData?: Record | null; + } = {} +) { + const { + listData = [], + docOk = true, + docData = { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } + } = opts; -describe('geschichten page load — documentId filter', () => { - it('passes a valid documentId to the geschichten API', async () => { + const mockGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/documents/{id}') { + return Promise.resolve({ + response: { ok: docOk, status: docOk ? 200 : 404 }, + data: docOk ? docData : undefined + }); + } + return Promise.resolve({ + response: { ok: true, status: 200 }, + data: listData + }); + }); + + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + return mockGet; +} + +describe('geschichten page load — documentFilter title resolution', () => { + it('resolves document title when documentId is a valid UUID and document exists', async () => { + mockApi({ docData: { id: VALID_UUID, title: 'Brief an Oma', originalFilename: 'brief.jpg' } }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'Brief an Oma' }); + }); + + it('falls back to originalFilename when document title is null', async () => { + mockApi({ docData: { id: VALID_UUID, title: null, originalFilename: 'scan_001.jpg' } }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: 'scan_001.jpg' }); + }); + + it('degrades to {id, title: null} on 404 without throwing (resolves, never rejects)', async () => { + // Explicit .resolves locks the no-throw guarantee — if error() were called, this would reject + mockApi({ docOk: false }); + + await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({ + documentFilter: { id: VALID_UUID, title: null } + }); + }); + + it('treats 403 identically to 404 — no oracle, loader still resolves', async () => { + // Permanent regression test: loader must not call getErrorMessage/throw on a forbidden title fetch. + // If it did, this assertion would fail with a rejection instead of a resolution. + const mockGet = vi.fn().mockImplementation((path: string) => { + if (path === '/api/documents/{id}') { + return Promise.resolve({ response: { ok: false, status: 403 }, data: undefined }); + } + return Promise.resolve({ response: { ok: true, status: 200 }, data: [] }); + }); + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + await expect(callLoad(makeUrl({ documentId: VALID_UUID }))).resolves.toMatchObject({ + documentFilter: { id: VALID_UUID, title: null } + }); + }); + + it('list still populates when title fetch returns 404 (independent results)', async () => { + mockApi({ + listData: [{ id: 'g1', title: 'Some Story' }], + docOk: false + }); + + const result = await callLoad(makeUrl({ documentId: VALID_UUID })); + + expect(result.geschichten).toHaveLength(1); + expect(result.documentFilter).toEqual({ id: VALID_UUID, title: null }); + }); + + it('returns null documentFilter when documentId is syntactically invalid', async () => { + mockApi(); + + const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + expect(result.documentFilter).toBeNull(); + }); + + it('does not fetch document title when documentId is invalid', async () => { + const mockGet = mockApi(); + + await callLoad(makeUrl({ documentId: 'not-a-uuid' })); + + expect(mockGet).not.toHaveBeenCalledWith('/api/documents/{id}', expect.anything()); + }); + + it('returns null documentFilter when documentId is absent', async () => { + mockApi(); + + const result = await callLoad(makeUrl()); + + expect(result.documentFilter).toBeNull(); + }); + + it('passes valid documentId to the geschichten API', async () => { const mockGet = mockApi(); await callLoad(makeUrl({ documentId: VALID_UUID })); @@ -61,38 +159,13 @@ describe('geschichten page load — documentId filter', () => { ); }); - it('omits documentId from the API call when the value is not a UUID', async () => { + it('passes invalid documentId to the list API without stripping (option B)', async () => { const mockGet = mockApi(); await callLoad(makeUrl({ documentId: 'not-a-uuid' })); - const query = mockGet.mock.calls[0][1].params.query; - expect(query.documentId).toBeUndefined(); - }); - - it('omits documentId from the API call when the param is absent', async () => { - const mockGet = mockApi(); - - await callLoad(makeUrl()); - - const query = mockGet.mock.calls[0][1].params.query; - expect(query.documentId).toBeUndefined(); - }); - - it('returns documentIdFilter in page data when a valid documentId is given', async () => { - mockApi(); - - const result = await callLoad(makeUrl({ documentId: VALID_UUID })); - - expect(result.documentIdFilter).toBe(VALID_UUID); - }); - - it('returns null documentIdFilter when documentId is invalid', async () => { - mockApi(); - - const result = await callLoad(makeUrl({ documentId: 'not-a-uuid' })); - - expect(result.documentIdFilter).toBeNull(); + const listCall = mockGet.mock.calls.find((c) => c[0] === '/api/geschichten'); + expect(listCall?.[1]?.params?.query?.documentId).toBe('not-a-uuid'); }); it('keeps forwarding personId filters alongside documentId', async () => { @@ -104,10 +177,7 @@ describe('geschichten page load — documentId filter', () => { '/api/geschichten', expect.objectContaining({ params: expect.objectContaining({ - query: expect.objectContaining({ - documentId: VALID_UUID, - personId: [VALID_UUID] - }) + query: expect.objectContaining({ documentId: VALID_UUID, personId: [VALID_UUID] }) }) }) ); -- 2.49.1 From dcc9a25fdc3b0ad3208c464b4318b632c5aa6300 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 09:12:37 +0200 Subject: [PATCH 6/8] feat(geschichten): add DocumentFilterChip component with spec Co-Authored-By: Claude Sonnet 4.6 --- frontend/.gitignore | 3 + .../geschichten/DocumentFilterChip.svelte | 35 ++++++++ .../DocumentFilterChip.svelte.spec.ts | 87 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 frontend/src/routes/geschichten/DocumentFilterChip.svelte create mode 100644 frontend/src/routes/geschichten/DocumentFilterChip.svelte.spec.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index 8617ce82..39081d9a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -13,6 +13,9 @@ node_modules .DS_Store Thumbs.db +# Leftover directory from branch work +/src.main/ + # Env .env .env.* diff --git a/frontend/src/routes/geschichten/DocumentFilterChip.svelte b/frontend/src/routes/geschichten/DocumentFilterChip.svelte new file mode 100644 index 00000000..4a0583b3 --- /dev/null +++ b/frontend/src/routes/geschichten/DocumentFilterChip.svelte @@ -0,0 +1,35 @@ + + +
+ + {m.geschichten_filter_document_chip()} + + + {chipLabel} + + +
diff --git a/frontend/src/routes/geschichten/DocumentFilterChip.svelte.spec.ts b/frontend/src/routes/geschichten/DocumentFilterChip.svelte.spec.ts new file mode 100644 index 00000000..9b5b6576 --- /dev/null +++ b/frontend/src/routes/geschichten/DocumentFilterChip.svelte.spec.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +import DocumentFilterChip from './DocumentFilterChip.svelte'; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const VALID_UUID = '11111111-2222-3333-4444-555555555555'; + +describe('DocumentFilterChip', () => { + it('renders the resolved document title inside the chip', async () => { + render(DocumentFilterChip, { + props: { + id: VALID_UUID, + title: 'Brief an Oma', + onremove: vi.fn() + } + }); + + await expect.element(page.getByText(/Brief an Oma/)).toBeVisible(); + }); + + it('renders the prefix label', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() } + }); + + await expect.element(page.getByText(/Gefiltert nach Brief/)).toBeVisible(); + }); + + it('falls back to short UUID when title is null', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: null, onremove: vi.fn() } + }); + + await expect.element(page.getByText(/11111111/)).toBeVisible(); + }); + + it('fires onremove when the remove button is clicked', async () => { + const onremove = vi.fn(); + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove } + }); + + const btn = (await page + .getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }) + .element()) as HTMLElement; + btn.click(); + + await vi.waitFor(() => expect(onremove).toHaveBeenCalledOnce()); + }); + + it('remove button aria-label references the resolved title', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() } + }); + + const btn = page.getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }); + await expect.element(btn).toBeVisible(); + }); + + it('title= attribute equals the validated id, not a raw query string', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() } + }); + + const chip = document.querySelector('[title]'); + expect(chip?.getAttribute('title')).toBe('Brief an Oma'); + }); + + it('remove button has a minimum 44px touch target', async () => { + render(DocumentFilterChip, { + props: { id: VALID_UUID, title: 'Brief an Oma', onremove: vi.fn() } + }); + + const btn = (await page + .getByRole('button', { name: /Brief an Oma aus Filter entfernen/ }) + .element()) as HTMLElement; + expect(btn.className).toMatch(/min-h-\[44px\]|min-h-11/); + }); +}); -- 2.49.1 From 1de10986c31477d5647fd07c75e6b9c4a741bf58 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 12 Jun 2026 09:27:20 +0200 Subject: [PATCH 7/8] feat(geschichten): integrate DocumentFilterChip into list page Add DocumentFilterChip to the filter bar, extract emptyMessage as $derived.by() with person-wins precedence, and add removeDocument navigation helper. Update tests: add document-filter chip and empty-state-precedence suites, fix person-chip click test to use native element.click() + vi.waitFor() for reliable Svelte 5 onclick triggering. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/geschichten/+page.svelte | 36 +++- .../routes/geschichten/page.svelte.spec.ts | 161 +++++++++++++++++- 2 files changed, 187 insertions(+), 10 deletions(-) diff --git a/frontend/src/routes/geschichten/+page.svelte b/frontend/src/routes/geschichten/+page.svelte index 3e4e681a..35d67301 100644 --- a/frontend/src/routes/geschichten/+page.svelte +++ b/frontend/src/routes/geschichten/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import { m } from '$lib/paraglide/messages.js'; import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import GeschichteListRow from '$lib/geschichte/GeschichteListRow.svelte'; +import DocumentFilterChip from './DocumentFilterChip.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -10,11 +11,24 @@ let { data }: { data: PageData } = $props(); let showPersonPicker = $state(false); const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!)); -const hasFilters = $derived(data.personFilters.length > 0); +const hasFilters = $derived(data.personFilters.length > 0 || data.documentFilter !== null); + +const emptyMessage = $derived.by(() => { + if (data.personFilters.length > 0) { + return m.geschichten_empty_for_persons({ + names: data.personFilters.map((p) => p.displayName).join(' & ') + }); + } + if (data.documentFilter) { + return m.geschichten_empty_for_document(); + } + return m.geschichten_empty_no_filter(); +}); function rebuildUrl(personIds: string[]) { const url = new URL(window.location.href); url.searchParams.delete('personId'); + url.searchParams.delete('documentId'); for (const id of personIds) url.searchParams.append('personId', id); return url.pathname + url.search; } @@ -35,6 +49,10 @@ function addPerson(personId: string) { function removePerson(personId: string) { goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId))); } + +function removeDocument() { + goto(rebuildUrl(selectedPersonIds)); +}
@@ -76,6 +94,14 @@ function removePerson(personId: string) { {/each} + {#if data.documentFilter} + + {/if} +