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>
79 lines
5.1 KiB
Markdown
79 lines
5.1 KiB
Markdown
# 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`.
|