docs(adr): ADR-045 + RTM rows for the #827 grouping modes
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m50s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 5m7s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 16s
SDD Gate / Contract Validate (pull_request) Successful in 23s
SDD Gate / Constitution Impact (pull_request) Successful in 15s

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>
This commit is contained in:
Marcel
2026-06-15 11:08:02 +02:00
parent 9551bbd1ca
commit b54a35322b
3 changed files with 120 additions and 21 deletions

View File

@@ -0,0 +1,78 @@
# 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`.