Files
familienarchiv/docs/adr/045-timeline-client-side-regroup.md
Marcel b54a35322b
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
docs(adr): ADR-045 + RTM rows for the #827 grouping modes
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>
2026-06-15 11:08:02 +02:00

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.

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 passresolveLetterEventLinks 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 LetterBuckets 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.