As a family member I want to regroup the /zeitstrahl by Ereignis or Thema so I can read letters clustered narratively, not just chronologically #827
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
As a family member I want to regroup the /zeitstrahl by Ereignis or Thema so I can read letters clustered narratively, not just chronologically
Milestone: Zeitstrahl — Family Timeline (#14)
Deferred from: #779 (global timeline ships Datum-only). Canonical visual spec:
docs/specs/zeitstrahl-final-spec.html§2 "Die drei Gruppierungs-Modi".Context & Why
The Concept-A mockup shows a top-right segmented control — Datum · Ereignis · Thema — that changes only how loose letters are bundled. Life-event pills, curated event pills, and world-bands stay fixed on the axis in every mode. #779 shipped Datum (chronological) and deferred the toggle because the other two modes need data
TimelineEntryDTOdoes not yet carry: a letter's curated-event association (Ereignis) and a letter's primary root tag (Thema).Constitution principles this feature depends on (see
.specify/constitution.md):TimelineService→tag-repo reach.{...}escaping, no{@html}) — tag-name chips render curator-authored text.Related: follow-up to #779; reuses the #779
lib/timeline/component tree, theTimelineEntryDTOcontract, and ADR-040 (timeline data model) / ADR-036 (in-transaction views).Resolved Decisions (were Open Questions)
The three #779 open questions are now resolved (see ADR-044 — to be written/committed before or with implementation; next free number 044 verified,
docs/adr/tops at 043):grouping=query param.GET /api/timelinealready returns the full timeline in one payload; regrouping is an in-memory presentation transform inlib/timeline/. Rejected server-sidegrouping=DATE|EVENT|TOPIC(adds lasting API surface and a bucket query for zero benefit on already-loaded data). Bounds the blast radius to the read view.timeline_events↔documentsManyToMany. ATimelineEventalready has a ManyToManydocumentsset (ADR-040; join tabletimeline_event_documents,@BatchSize(50)). A letter clusters under a curated event iff that event'sdocumentscontains the letter's document. No new column/table, no Flyway migration. Rejected a new persisted FK on the document/letter row (duplicates existing capability, opens a mutating write path + migration for no gain).Tag.parentIdto its root). No "primary" flag added to the schema. Documented in the UI hint "Brief mit mehreren Tags erscheint unter seinem primären Tag."User Journey
A family reader opens
/zeitstrahl; it loads in Datum mode (chronological, identical to #779). They click Ereignis in the top-right segmented control: loose letters re-bundle in place — each letter clusters under the curated event whose documents include it (e.g. "Briefe von der Front · 24" under that event's pill); letters belonging to no curated event drop into a per-year "Weitere Briefe" bucket. They click Thema: loose letters re-bucket per year under their primary root tag (e.g. "Krieg › Briefe von der Front · 24", "Weihnachten · 3"); untagged letters fall into a per-year "Ohne Thema" bucket; a multi-tagged letter appears under exactly one tag. Throughout, the axis-fixed layers — derived life-events, curated event pills, world-bands — do not move or change. Switching back to Datum restores the chronological view. The control is keyboard-navigable and screen-reader-announced; on a 320px phone it stays usable without overflow.Requirements
/zeitstrahlview shall render the axis-fixed layers (derived life-events, curated event pills, world-bands) identically in all three grouping modes; only loose-letter bundling changes.GET /api/timeline.TimelineEventwhosedocumentsset contains that letter's document.TimelineEntryDTOshall carry, forLETTERentries, a nullablelinkedEventId(UUID),rootTagId(UUID) androotTagName(String), assembled in-transaction inTimelineService(ADR-036).@Schema(requiredMode = REQUIRED)(they are null for non-letter entries and for letters with no event/tag).linkedEventId == null), then the view shall place it in a per-year "Weitere Briefe" bucket (fall back, never hide).rootTagId == null), then the view shall place it in a per-year "Ohne Thema" bucket.{...}escaping and shall never use{@html}for curator-authored text (CWE-79).role="radiogroup"(arrow-key reachable), each segment ≥44×44px with a text label (not color-only), built from semantic tokens (bg-surface/text-ink-3, active =brand-navy) so the selected/unselected contrast holds in dark mode, the active mode announced to screen readers, defaulting to Datum.aria-labelso the screen-reader announcement stays unambiguous.messages/de.json,messages/en.json, andmessages/es.json— no German literal in markup.GET /api/timelinedata fails to load, the view shall surface the existing localized error state viagetErrorMessage(code); grouping itself, being client-side, has no independent failure mode.Acceptance Criteria
documentsset appears under E's cluster; the cluster count matches the number of such letters (mockup: "Briefe von der Front · 24" ⇒ 24).Krieg/Briefe von der Frontappears in the year bucket under root tagKrieg; counts match (e.g. "Weihnachten · 3" ⇒ 3).api.d.tsshowslinkedEventId?,rootTagId?,rootTagName?on the letter entry;npm run generate:apiis a task; no entity graph is serialized (ADR-036 view assertion).linkedEventId == nullrenders in the "Weitere Briefe" bucket of its year and in no event cluster.<img src=x onerror=alert(1)>renders as inert text; a grep assertion confirms no{@html}appears in anylib/timeline/component (including new regroup components).role="radio"inside arole="radiogroup"witharia-checked; (b) arrow keys move the selection; (c) each segment measures ≥44×44px; (d) each segment carries a text label, not color alone; (e) the active mode is announced to screen readers; (f) the control defaults to Datum; (g) an axe scan reports zero violations in both light and dark mode, with the selected segment keeping ≥3:1 contrast against its background on the dark theme.aria-labelequal to its full word.npm run lint(i18n check) passes with no missing-key warning.Out of Scope
grouping=query param — explicitly rejected.API / Contract Stub
GET /api/timeline— path, method, and@RequirePermission(Permission.READ_ALL)unchanged. ResponseTimelineEntryDTOgains three nullable fields onLETTERentries (record grows 13 → 16 components):No new endpoint, no mutating path. Run
npm run generate:apiafter the DTO change and commit the regeneratedapi.d.tsin the same PR so CI's type-check stays green.Data Model Changes
None. No new table or column and no Flyway migration. The letter→event association reuses the existing
timeline_events↔documentsManyToMany (tabletimeline_event_documents, ADR-040); the primary root tag is resolved at read time via the tag service. (For the record, the next free Flyway number isV78— not consumed by this feature.)Non-Functional Notes
TimelineServicegains aTagServicedependency (it does not inject one today) to resolve the root-tag — service-to-service per §1.3, neverTagRepository.TagServicehas no public root-tag resolver today, butTagRepository.findAncestorIds(UUID)(a recursive CTE with a depth guard) already exists; the implementer should add a public root-resolution method toTagService(e.g.Tag resolveRoot(UUID)) that wraps that CTE — one query per distinct tag, preferred over loopinggetByIdup theTag.parentIdchain — built as the batched/memoized pass below, not per-letter calls.mapDocumentruns per-letter today.linkedEventIdandrootTagId/rootTagNamemust be resolved in a single batched pass inside the existingTimelineServiceassembly transaction — one curated-event→documents map, plus one root-tag walk memoized per distinct tag (not per letter), reusingTagRepository.findAncestorIds.TimelineEvent.documentsalready carries@BatchSize(50). No new index required (reuses existing join keys).Security Considerations
Read-only feature on the family-only archive; no new mutating endpoint, so no new authn/authz
Ifclause beyond the existingREAD_ALLonGET /api/timeline.Primary code-level control: REQ-009 — tag-name chips and the multi-tag hint render via
{...}escaping, never{@html}(CWE-79); the grep gate covers everylib/timeline/component the regroup adds. DTO enrichment returns id + display name only, never a raw tag entity (ADR-036).Pre-Implementation Artifacts (commit before/with the code)
.specify/rtm.mdwith issue #827, Status=Planned. — owner: implementerapi.d.tscommitted in the implementation PR. — owner: implementerOpen Questions
Traceability
{@html}grep(After approval, mirror one row per REQ-NNN into
.specify/rtm.mdwith issue #827 — see Pre-Implementation Artifacts.)Persona Review Results
TagService.resolveRootwrapping the existingTagRepository.findAncestorIdsCTE, built as the batched/memoized pass{...}-escaping (REQ-009) with XSS payload AC +{@html}grep; read-only; STRIDE; UUID-only loggingaria-labelfull word (REQ-011) + abbrev i18n keys (REQ-012); dark-mode contrast tested@BatchSize(50)) + ADR-036 view; N+1 bounded; TagService edgeFour review-and-harden cycles complete (2026-06-14): the spec converged from a unanimous CHANGES-REQUESTED discussion stub to a unanimous APPROVE, implementation-ready SDD spec. Remaining work is execution hygiene tracked in Pre-Implementation Artifacts (ADR-044, RTM rows, regenerated
api.d.ts).