Cluster event letters inline in the chronological /zeitstrahl (no grouping toggle) #850

Closed
opened 2026-06-15 20:18:40 +02:00 by marcel · 0 comments
Owner

As a family reader I want a curated event's letters to appear together under that event on the chronological /zeitstrahl, while all other letters stay in plain chronological order — so the timeline reads narratively without a mode switch.

Milestone: Zeitstrahl — Family Timeline (#14)
Supersedes: #827 (and PR #847). #827 shipped a Datum · Ereignis · Thema grouping toggle; after building it we decided a single, toggle-free chronological view reads better. This issue keeps #827's event-card clustering and its computed linkedEventId, and drops the toggle, the Thema mode, and the "Weitere Briefe" drawer. Branch off current main (which never carried the toggle — #847 is not merged).

Context & Why

/zeitstrahl (#779/#833) is a single centered-axis chronological timeline: derived life-events and curated-event pills + world-bands sit centered on the spine; letters alternate left/right and fold into a month-density strip (YearLetterStrip) once a year holds >12. #835/PR #838 added per-letter root-tag chips; #780 added a client-side layer filter; #842 added the curator "+ Ereignis" CTA + edit pencils.

This issue makes one change to that view: a curated event that has letters linked to it becomes a contained card (the event is the card's header; its letters sit inside) instead of a bare pill, and those letters are pulled out of the loose chronological flow into the card. Every other letter is unchanged — it stays a loose, alternating, density-folding chronological letter. There is no grouping control: clustering is automatic and always on.

The letter→event association is the computed linkedEventId from #827 (the curated event whose documents set contains the letter's document; reuses the timeline_event_documents join; no new column/migration).

User Journey

A reader opens /zeitstrahl. In 1916 they see the curated event "Ein gewaltiger Stadtbrand" rendered as a mint-bordered card — its header carries the ★ glyph, title, "6. Juli 1916 · kuratiert", and (for a curator) an edit ✎ — with its linked letters listed inside (the first 5, then "+ N weitere Briefe anzeigen"). Just below, ordinary letters from 1916 that belong to no event sit on the spine alternating left/right, and because there are many, they fold into the familiar month-density strip. The multi-year event "Briefe von der Front" shows its card (with the pill chrome) in its own year and a lighter labeled card "✉ Briefe von der Front · 1917" in 1917 holding that year's front letters. Derived life-events (Geburt/Tod/Heirat) and world-bands (Verdun, Somme) render exactly as before. There is no mode switch anywhere.

Requirements

  • REQ-001 (Ubiquitous) — /zeitstrahl shall render a single chronological timeline with no grouping-mode control (no Datum/Ereignis/Thema toggle, no Thema mode, no "Weitere Briefe"/"Ohne Thema" drawer).
  • REQ-002 (State-driven) — While a curated event has ≥1 linked letter (linkedEventId) in a given year band, the view shall render that event as a contained card whose header is the event (accent glyph + sr-only label, title, {date} · {kuratiert|abgeleitet} subtitle, and — for a viewer with WRITE_ALL — an edit link to /zeitstrahl/events/{eventId}/edit) and whose body lists that year's linked letters as compact cards.
  • REQ-003 (State-driven) — While a curated event's card body holds more than 5 letters, the view shall show the first 5 and a keyboard-operable show-more/less toggle (aria-expanded, ≥44×44px) that reveals/collapses the rest.
  • REQ-004 (State-driven) — While a curated event has linked letters in a year other than the event's own band, the view shall render a labeled card (header = the event title with a ✉ glyph, no pill chrome, no edit link) in each such year, holding that year's linked letters (same first-5 + show-more body).
  • REQ-005 (Ubiquitous) — A curated event, derived life-event, or world-band with no linked letters in a band shall render as its existing plain pill / world-band (unchanged from #779/#833/#842).
  • REQ-006 (Ubiquitous) — A letter linked to no curated event shall render as a loose chronological letter: alternating left/right ≥1024px, folding into the YearLetterStrip month-density strip when a band holds >12 such loose letters (unchanged #779/#833 behavior).
  • REQ-007 (Ubiquitous) — The loose-letter layout and the density strip shall count only non-event-linked letters; a letter clustered in an event card shall never also appear as a loose letter (no duplication).
  • REQ-008 (Unwanted-behavior) — If a letter's only linking curated event is hidden by the #780 layer filter (absent from the filtered view), then the letter shall render as a loose chronological letter — never clustered under, nor re-introducing, a filtered-out event (filter-then-cluster).
  • REQ-009 (Ubiquitous) — TimelineEntryDTO shall carry, for LETTER entries, a nullable linkedEventId (UUID; the curated event whose documents set contains the letter's document), assembled in one batched membership pass in TimelineService over the events it already loads — no per-letter query, no new column, no Flyway migration (carried over from #827; not @Schema(requiredMode = REQUIRED)). When a document is referenced by more than one curated event, the link shall resolve deterministically (earliest event date, then eventId), independent of repository iteration order.
  • REQ-010 (Ubiquitous) — Event titles, letter titles, and sender/receiver text shall render through Svelte default {...} escaping; {@html} shall never appear in any lib/timeline/ component (CWE-79; grep gate).
  • REQ-011 (Ubiquitous) — The wrapping header shall keep the #842 "Ereignis hinzufügen" CTA and the #780 layer-filter trigger; the meta-line shall drop its grouping segment (no "Gruppierung: …").
  • REQ-012 (Ubiquitous) — Show-more/less labels shall be new Paraglide keys present in messages/{de,en,es}.json; the grouping/Thema keys introduced by #827 (timeline_grouping_* segments, timeline_bucket_no_topic, etc.) shall be removed if not reused.
  • REQ-013 (Optional-feature) — Where GET /api/timeline fails to load, the view shall surface the existing localized error state via getErrorMessage(code) (unchanged #779).
  • REQ-014 (Ubiquitous) — A curated event of type HISTORICAL shall always render as its full-width world band (per #779 REQ-009), never as a contained event card, even when letters link to it; those letters render as loose chronological letters. (Added during PR review to close a spec gap the Requirements Engineer flagged: REQ-002 did not distinguish PERSONAL vs HISTORICAL.)
  • REQ-015 (Ubiquitous) — A cross-year card (REQ-004) shall be placed at the chronological position of its earliest linked letter within the band — never appended after later-dated loose letters — so the band reads in strict time order. (Added during PR review to close a spec gap: REQ-001's "single chronological timeline" did not pin the cross-year card's position.)

Acceptance Criteria

  • REQ-001 — No element with role="radiogroup"/data-testid="grouping-control" renders; no timeline-bucket with data-bucket-kind="fallback"/"tag" renders.
  • REQ-002 — A year with a curated event whose documents include a same-year letter renders one data-testid="event-card" whose header contains the event title once and (with canWrite) an event-edit link; the letter renders inside it.
  • REQ-003 — An event card with 8 linked letters shows 5 compact cards + a bucket-show-more toggle; clicking shows 8, clicking again 5.
  • REQ-004 — A letter linked to a 1916 event but dated 1917 renders inside a labeled card in the 1917 band (title present, no event-edit, no pill); the 1916 event card is unaffected.
  • REQ-005 — A curated event with no linked letters renders an EventPill/WorldBand, no card.
  • REQ-006/007 — In a band of 15 loose letters + one 3-letter event, the event card shows 3 letters and the loose letters fold into one YearLetterStrip whose count is 15 (not 18).
  • REQ-008 — With the event's layer toggled off in #780, its formerly-clustered letters render as loose cards/strip; no event card for it.
  • REQ-009TimelineServiceTest asserts linkedEventId set for a linked letter and null for an unlinked one in one batched pass; a letter in two events' documents resolves to the same (deterministic) event id regardless of input order; regenerated frontend/src/lib/generated/api.ts shows linkedEventId?.
  • REQ-010 — A tag/title bearing <img onerror> renders inert; grep -rn '@html' frontend/src/lib/timeline/ → zero.
  • REQ-011 — The add-event CTA + filter trigger still render; the meta-line text contains no "Gruppierung".
  • REQ-012 — All locales carry the new show-more/less keys; messages.spec.ts parity passes; no removed grouping key is still referenced.
  • REQ-014 — A HISTORICAL curated event with a same-year linked letter renders a WorldBand (no data-testid="event-card"); the letter renders as a loose chronological letter, not inside a card.
  • REQ-015 — In a band holding a loose letter dated November and a cross-year cluster of letters dated February, the cross-year event-card renders before the November loose letter in DOM order.

Out of Scope

  • A grouping toggle, Thema/by-tag clustering, the "Weitere Briefe"/"Ohne Thema" drawer — removed.
  • Datum-mode chronological behavior for loose letters (unchanged).
  • Backend beyond the computed linkedEventId (no migration, no new endpoint).
  • The per-letter root-tag chip (#835) — unchanged; still shown on loose letters.

Data Model / Contract

None changed. GET /api/timeline keeps its path/method/READ_ALL. TimelineEntryDTO gains the computed nullable linkedEventId (reuses timeline_event_documents, ADR-040; one batched pass; @BatchSize(50)). Run npm run generate:api and commit the regenerated api.ts.

Security Considerations

Read-only; no new mutating endpoint, no new authn/authz clause. linkedEventId exposes only an event id a READ_ALL reader already sees. Primary control: REQ-010 {...} escaping + the lib/timeline {@html} grep gate.

Notes

  • Branch off current main. The backend linkedEventId + the event-card frontend (LetterBucket event-header path, compact LetterCard, first-5/show-more) can be cherry-picked from the superseded feat/issue-827-zeitstrahl-grouping branch; the toggle, GroupingControl, BucketHeaderChip, Thema buckets, and the fallback drawer are not carried over.
  • RTM rows REQ-001..015 to be added with this issue number, Status Planned → Done as tasks land.
  • REQ-014 / REQ-015 were added during the PR #851 review to close the two under-specified interactions the Requirements Engineer flagged (HISTORICAL clustering, cross-year ordering).
# As a family reader I want a curated event's letters to appear together under that event on the chronological /zeitstrahl, while all other letters stay in plain chronological order — so the timeline reads narratively without a mode switch. **Milestone:** Zeitstrahl — Family Timeline (#14) **Supersedes:** #827 (and PR #847). #827 shipped a `Datum · Ereignis · Thema` grouping *toggle*; after building it we decided a single, toggle-free chronological view reads better. This issue keeps #827's **event-card clustering** and its computed `linkedEventId`, and drops the **toggle**, the **Thema** mode, and the **"Weitere Briefe" drawer**. Branch off current `main` (which never carried the toggle — #847 is not merged). ## Context & Why `/zeitstrahl` (#779/#833) is a single centered-axis chronological timeline: derived life-events and curated-event pills + world-bands sit centered on the spine; letters alternate left/right and fold into a month-density strip (`YearLetterStrip`) once a year holds >12. #835/PR #838 added per-letter root-tag chips; #780 added a client-side layer filter; #842 added the curator "+ Ereignis" CTA + edit pencils. This issue makes one change to that view: **a curated event that has letters linked to it becomes a contained card** (the event is the card's header; its letters sit inside) instead of a bare pill, and those letters are pulled out of the loose chronological flow into the card. Every other letter is unchanged — it stays a loose, alternating, density-folding chronological letter. There is **no grouping control**: clustering is automatic and always on. The letter→event association is the computed `linkedEventId` from #827 (the curated event whose `documents` set contains the letter's document; reuses the `timeline_event_documents` join; no new column/migration). ## User Journey A reader opens `/zeitstrahl`. In 1916 they see the curated event **"Ein gewaltiger Stadtbrand"** rendered as a mint-bordered card — its header carries the ★ glyph, title, "6. Juli 1916 · kuratiert", and (for a curator) an edit ✎ — with its linked letters listed inside (the first 5, then "+ N weitere Briefe anzeigen"). Just below, ordinary letters from 1916 that belong to no event sit on the spine alternating left/right, and because there are many, they fold into the familiar month-density strip. The multi-year event **"Briefe von der Front"** shows its card (with the pill chrome) in its own year and a lighter labeled card "✉ Briefe von der Front · 1917" in 1917 holding that year's front letters. Derived life-events (Geburt/Tod/Heirat) and world-bands (Verdun, Somme) render exactly as before. There is no mode switch anywhere. ## Requirements - **REQ-001** (Ubiquitous) — `/zeitstrahl` shall render a single chronological timeline with **no grouping-mode control** (no `Datum/Ereignis/Thema` toggle, no Thema mode, no "Weitere Briefe"/"Ohne Thema" drawer). - **REQ-002** (State-driven) — While a curated event has ≥1 linked letter (`linkedEventId`) in a given year band, the view shall render that event as a **contained card** whose header is the event (accent glyph + sr-only label, title, `{date} · {kuratiert|abgeleitet}` subtitle, and — for a viewer with `WRITE_ALL` — an edit link to `/zeitstrahl/events/{eventId}/edit`) and whose body lists that year's linked letters as compact cards. - **REQ-003** (State-driven) — While a curated event's card body holds more than 5 letters, the view shall show the first 5 and a keyboard-operable show-more/less toggle (`aria-expanded`, ≥44×44px) that reveals/collapses the rest. - **REQ-004** (State-driven) — While a curated event has linked letters in a year **other than** the event's own band, the view shall render a labeled card (header = the event title with a ✉ glyph, no pill chrome, no edit link) in each such year, holding that year's linked letters (same first-5 + show-more body). - **REQ-005** (Ubiquitous) — A curated event, derived life-event, or world-band with **no** linked letters in a band shall render as its existing plain pill / world-band (unchanged from #779/#833/#842). - **REQ-006** (Ubiquitous) — A letter linked to no curated event shall render as a loose chronological letter: alternating left/right ≥1024px, folding into the `YearLetterStrip` month-density strip when a band holds >12 such loose letters (unchanged #779/#833 behavior). - **REQ-007** (Ubiquitous) — The loose-letter layout and the density strip shall count **only** non-event-linked letters; a letter clustered in an event card shall never also appear as a loose letter (no duplication). - **REQ-008** (Unwanted-behavior) — If a letter's only linking curated event is hidden by the #780 layer filter (absent from the filtered view), then the letter shall render as a loose chronological letter — never clustered under, nor re-introducing, a filtered-out event (filter-then-cluster). - **REQ-009** (Ubiquitous) — `TimelineEntryDTO` shall carry, for `LETTER` entries, a nullable `linkedEventId` (UUID; the curated event whose `documents` set contains the letter's document), assembled in one batched membership pass in `TimelineService` over the events it already loads — no per-letter query, no new column, no Flyway migration (carried over from #827; **not** `@Schema(requiredMode = REQUIRED)`). When a document is referenced by more than one curated event, the link shall resolve **deterministically** (earliest event date, then `eventId`), independent of repository iteration order. - **REQ-010** (Ubiquitous) — Event titles, letter titles, and sender/receiver text shall render through Svelte default `{...}` escaping; `{@html}` shall never appear in any `lib/timeline/` component (CWE-79; grep gate). - **REQ-011** (Ubiquitous) — The wrapping header shall keep the #842 "Ereignis hinzufügen" CTA and the #780 layer-filter trigger; the meta-line shall drop its grouping segment (no "Gruppierung: …"). - **REQ-012** (Ubiquitous) — Show-more/less labels shall be new Paraglide keys present in `messages/{de,en,es}.json`; the grouping/Thema keys introduced by #827 (`timeline_grouping_*` segments, `timeline_bucket_no_topic`, etc.) shall be removed if not reused. - **REQ-013** (Optional-feature) — Where `GET /api/timeline` fails to load, the view shall surface the existing localized error state via `getErrorMessage(code)` (unchanged #779). - **REQ-014** (Ubiquitous) — A curated event of type `HISTORICAL` shall always render as its full-width world band (per #779 REQ-009), **never** as a contained event card, even when letters link to it; those letters render as loose chronological letters. *(Added during PR review to close a spec gap the Requirements Engineer flagged: REQ-002 did not distinguish PERSONAL vs HISTORICAL.)* - **REQ-015** (Ubiquitous) — A cross-year card (REQ-004) shall be placed at the chronological position of its **earliest linked letter** within the band — never appended after later-dated loose letters — so the band reads in strict time order. *(Added during PR review to close a spec gap: REQ-001's "single chronological timeline" did not pin the cross-year card's position.)* ## Acceptance Criteria - **REQ-001** — No element with `role="radiogroup"`/`data-testid="grouping-control"` renders; no `timeline-bucket` with `data-bucket-kind="fallback"`/`"tag"` renders. - **REQ-002** — A year with a curated event whose `documents` include a same-year letter renders one `data-testid="event-card"` whose header contains the event title once and (with `canWrite`) an `event-edit` link; the letter renders inside it. - **REQ-003** — An event card with 8 linked letters shows 5 compact cards + a `bucket-show-more` toggle; clicking shows 8, clicking again 5. - **REQ-004** — A letter linked to a 1916 event but dated 1917 renders inside a labeled card in the 1917 band (title present, no `event-edit`, no pill); the 1916 event card is unaffected. - **REQ-005** — A curated event with no linked letters renders an `EventPill`/`WorldBand`, no card. - **REQ-006/007** — In a band of 15 loose letters + one 3-letter event, the event card shows 3 letters and the loose letters fold into one `YearLetterStrip` whose count is 15 (not 18). - **REQ-008** — With the event's layer toggled off in #780, its formerly-clustered letters render as loose cards/strip; no event card for it. - **REQ-009** — `TimelineServiceTest` asserts `linkedEventId` set for a linked letter and null for an unlinked one in one batched pass; a letter in two events' `documents` resolves to the same (deterministic) event id regardless of input order; regenerated `frontend/src/lib/generated/api.ts` shows `linkedEventId?`. - **REQ-010** — A tag/title bearing `<img onerror>` renders inert; `grep -rn '@html' frontend/src/lib/timeline/` → zero. - **REQ-011** — The add-event CTA + filter trigger still render; the meta-line text contains no "Gruppierung". - **REQ-012** — All locales carry the new show-more/less keys; `messages.spec.ts` parity passes; no removed grouping key is still referenced. - **REQ-014** — A HISTORICAL curated event with a same-year linked letter renders a `WorldBand` (no `data-testid="event-card"`); the letter renders as a loose chronological letter, not inside a card. - **REQ-015** — In a band holding a loose letter dated November and a cross-year cluster of letters dated February, the cross-year `event-card` renders **before** the November loose letter in DOM order. ## Out of Scope - A grouping toggle, Thema/by-tag clustering, the "Weitere Briefe"/"Ohne Thema" drawer — **removed**. - Datum-mode chronological behavior for loose letters (unchanged). - Backend beyond the computed `linkedEventId` (no migration, no new endpoint). - The per-letter root-tag chip (#835) — unchanged; still shown on loose letters. ## Data Model / Contract **None changed.** `GET /api/timeline` keeps its path/method/`READ_ALL`. `TimelineEntryDTO` gains the computed nullable `linkedEventId` (reuses `timeline_event_documents`, ADR-040; one batched pass; `@BatchSize(50)`). Run `npm run generate:api` and commit the regenerated `api.ts`. ## Security Considerations Read-only; no new mutating endpoint, no new authn/authz clause. `linkedEventId` exposes only an event id a `READ_ALL` reader already sees. Primary control: REQ-010 `{...}` escaping + the `lib/timeline` `{@html}` grep gate. ## Notes - Branch off current `main`. The backend `linkedEventId` + the event-card frontend (`LetterBucket` event-header path, compact `LetterCard`, first-5/show-more) can be cherry-picked from the superseded `feat/issue-827-zeitstrahl-grouping` branch; the toggle, `GroupingControl`, `BucketHeaderChip`, Thema buckets, and the fallback drawer are **not** carried over. - RTM rows REQ-001..015 to be added with this issue number, Status Planned → Done as tasks land. - **REQ-014 / REQ-015 were added during the PR #851 review** to close the two under-specified interactions the Requirements Engineer flagged (HISTORICAL clustering, cross-year ordering).
marcel added the P2-mediumfeaturespec-requiredui labels 2026-06-15 20:19:29 +02:00
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-15 20:19:30 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#850