Record the three #827 forks (client-side regroup transport, computed letter→event link reusing timeline_event_documents, filter-then-group composition with #780) as ADR-045, trace REQ-001..019 (+005b) into the RTM as Done, and list the new timeline components in the frontend domain inventory. Refs #827 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.1 KiB
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).
buildEventLookupis 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 toTimelineEntryDTO(17 components); the regeneratedfrontend/src/lib/generated/api.tsis committed in the same PR. No table, column, Flyway migration, endpoint,ErrorCode, orPermissionchanges. - The regroup is pure and fully unit-tested independently of the components;
TimelineView/YearBandrender the axis-fixed event layer identically across all three modes (REQ-001) and only swap the loose-letter rendering for per-yearLetterBuckets off Datum. - The new Thema bucket-header chip (
BucketHeaderChip) is a filled variant tinted fromrootTagColor; the shipped neutral per-letterTagChip(#838) is reused as-is and suppressed inside its own bucket (REQ-017). Alllib/timelinecomponents keep the{...}-escaping guarantee — a grep gate forbids{@html}(REQ-009). - Read-only feature: no new authn/authz surface beyond the existing
READ_ALLonGET /api/timeline.