# ADR-045 — The /zeitstrahl Ereignis/Thema regroup is client-side, over a computed letter→event link **Status:** Accepted **Date:** 2026-06-15 **Issue:** #827 (Zeitstrahl milestone; deferred follow-up to #779, builds on #835/PR #838 and #780) ## Context #779 shipped `/zeitstrahl` in **Datum** mode only and deferred the Concept-A **Datum · Ereignis · Thema** segmented control, because the other two modes need data the `TimelineEntryDTO` did not carry: a letter's curated-event association (Ereignis) and a letter's primary root tag + colour (Thema). #835 (merged in PR #838) added the Thema fields (`rootTagId`/`rootTagName`/`rootTagColor`) and the batched `TimelineService → TagService` resolver. Meanwhile #780 added the **layer filter** — `/zeitstrahl/+page.svelte` owns `personalOn`/`historicalOn`/`lettersOn` `$state` and renders `TimelineView` over a client-side `filterTimeline(data.timeline, …)` view. This ADR records the three forks specific to **#827** (the Thema enrichment + the `TimelineService → TagService` edge are #835's scope, not this one). ## Decisions ### 1. Grouping is a client-side presentation transform — no `grouping=` query param `GET /api/timeline` already returns the whole timeline in one payload. Regrouping the loose letters is an in-memory transform in `lib/timeline/timelineGrouping.ts` (`bucketLetters`, `buildEventLookup`, `hasLooseLetters`), driven by a `groupingMode` `$state` in `+page.svelte`. A server-side `grouping=DATE|EVENT|TOPIC` parameter was rejected: it would add lasting API surface and a bucket query for zero benefit on an already-loaded payload, and switching modes must issue **zero** extra fetches (REQ-002). The blast radius stays inside the read view. ### 2. The letter→event link is computed, reusing `timeline_event_documents` — no new column A letter clusters under a curated event iff that event's `documents` set (ADR-040; `@ManyToMany @BatchSize(50)` over join table `timeline_event_documents`) contains the letter's document. `TimelineService.assemble` resolves this in **one batched membership pass** — `resolveLetterEventLinks` builds a single `docId → eventId` map over the already-loaded events (no per-letter query), reusing the same `eventRepository.findAll()` it already iterates for the event entries. The result is exposed as one nullable DTO field, `linkedEventId`. A new persisted FK on the document/letter row was rejected: it duplicates an existing capability and opens a mutating write path + Flyway migration for no gain. **No new column, no migration, no new cross-domain edge** (the field derives from data `TimelineService` already loads). `linkedEventId` is deliberately **not** `@Schema(requiredMode = REQUIRED)` — it is null for non-letter entries and for letters under no curated event — so the generated TypeScript type stays optional. ### 3. Grouping composes with the #780 layer filter as **filter-then-group** The pipeline is `data.timeline → filterTimeline() (#780) → groupingMode transform → TimelineView`. The grouping `$state` lives in `+page.svelte` beside the filter `$state`, and the regroup runs over the layer-**filtered** view, never the raw `data.timeline`. Grouping the raw timeline and filtering afterward was rejected: the counts and buckets would disagree with the layer toggles, re-opening the #780 count-mismatch the page already closed. Two consequences fall out of filter-then-group: - **Letters layer off → the grouping control disables, kept in place (REQ-018).** With no loose letters in the filtered view there is nothing to regroup; the control renders `aria-disabled` (no header reflow), keeps its selected mode, and announces a screen-reader reason. - **A letter whose only linking event was filtered out falls back to "Weitere Briefe" (REQ-019).** `buildEventLookup` is built from the events present in the _filtered_ view, so Ereignis clusters only under events that survived the filter; everything else lands in the per-year fallback bucket. The control is a `role="radiogroup"` (single-select), deliberately distinct from #780's `aria-pressed` toggle filter, stacked above the filter trigger so the two read as one control cluster — the top-right corner stays the #842 add-event CTA. ## Consequences - One nullable field (`linkedEventId`) is added to `TimelineEntryDTO` (17 components); the regenerated `frontend/src/lib/generated/api.ts` is committed in the same PR. No table, column, Flyway migration, endpoint, `ErrorCode`, or `Permission` changes. - The regroup is pure and fully unit-tested independently of the components; `TimelineView`/ `YearBand` render the axis-fixed event layer identically across all three modes (REQ-001) and only swap the loose-letter rendering for per-year `LetterBucket`s off Datum. - The new Thema bucket-header chip (`BucketHeaderChip`) is a filled variant tinted from `rootTagColor`; the shipped neutral per-letter `TagChip` (#838) is reused as-is and suppressed inside its own bucket (REQ-017). All `lib/timeline` components keep the `{...}`-escaping guarantee — a grep gate forbids `{@html}` (REQ-009). - Read-only feature: no new authn/authz surface beyond the existing `READ_ALL` on `GET /api/timeline`.