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

Open
opened 2026-06-13 19:12:49 +02:00 by marcel · 0 comments
Owner

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 TimelineEntryDTO does 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):

  • §1.6 (lazy-collection views / ADR-036) — the new DTO fields are assembled in-transaction in the timeline view; no lazy entity collection is serialized.
  • §1.3 (cross-domain access via services) — Thema's root-tag lookup goes through the tag domain's service, never a TimelineService→tag-repo reach.
  • §2.5 (default Svelte {...} escaping, no {@html}) — tag-name chips render curator-authored text.

Related: follow-up to #779; reuses the #779 lib/timeline/ component tree, the TimelineEntryDTO contract, 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):

  1. Grouping transport → client-side regroup over an enriched DTO. No grouping= query param. GET /api/timeline already returns the full timeline in one payload; regrouping is an in-memory presentation transform in lib/timeline/. Rejected server-side grouping=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.
  2. Letter → curated-event link → computed, reusing the existing timeline_eventsdocuments ManyToMany. A TimelineEvent already has a ManyToMany documents set (ADR-040; join table timeline_event_documents, @BatchSize(50)). A letter clusters under a curated event iff that event's documents contains 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).
  3. Primary root tag for a multi-tagged letter → deterministic first-by-order: the root ancestor of the letter's alphabetically-first tag name (walking Tag.parentId to 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

  • REQ-001 (Ubiquitous) — The /zeitstrahl view 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.
  • REQ-002 (Event-driven) — When the user selects a grouping segment (Datum/Ereignis/Thema), the timeline shall re-bundle loose letters client-side from the already-loaded payload without re-fetching GET /api/timeline.
  • REQ-003 (State-driven) — While in Ereignis mode, the view shall cluster each loose letter under the curated TimelineEvent whose documents set contains that letter's document.
  • REQ-004 (State-driven) — While in Thema mode, the view shall bucket each loose letter, per year, under its primary root tag (root ancestor of its alphabetically-first tag).
  • REQ-005 (Ubiquitous) — The TimelineEntryDTO shall carry, for LETTER entries, a nullable linkedEventId (UUID), rootTagId (UUID) and rootTagName (String), assembled in-transaction in TimelineService (ADR-036).
  • REQ-005b (Ubiquitous) — The three new DTO fields shall not be marked @Schema(requiredMode = REQUIRED) (they are null for non-letter entries and for letters with no event/tag).
  • REQ-006 (Unwanted-behavior) — If a loose letter in Ereignis mode has no linked curated event (linkedEventId == null), then the view shall place it in a per-year "Weitere Briefe" bucket (fall back, never hide).
  • REQ-007 (Unwanted-behavior) — If a loose letter in Thema mode has no tag (rootTagId == null), then the view shall place it in a per-year "Ohne Thema" bucket.
  • REQ-008 (Unwanted-behavior) — If a loose letter has multiple tags, then the view shall display it under exactly one root tag (the deterministic primary per REQ-004), never duplicated across buckets.
  • REQ-009 (Ubiquitous) — The view shall render tag names and the multi-tag hint through Svelte default {...} escaping and shall never use {@html} for curator-authored text (CWE-79).
  • REQ-010 (Ubiquitous) — The grouping control shall be a keyboard-navigable 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.
  • REQ-011 (State-driven) — While the viewport is ≤320px wide, the grouping control shall remain fully visible and operable (no overflow, tap target ≥44px) via abbreviated/stacked labels, each abbreviation carrying its full word as an aria-label so the screen-reader announcement stays unambiguous.
  • REQ-012 (Ubiquitous) — The segment labels (Datum/Ereignis/Thema), their ≤320px abbreviations, the multi-tag hint, the bucket labels ("Weitere Briefe", "Ohne Thema"), and any count/label strings shall exist as Paraglide keys in messages/de.json, messages/en.json, and messages/es.json — no German literal in markup.
  • REQ-013 (Optional-feature) — Where GET /api/timeline data fails to load, the view shall surface the existing localized error state via getErrorMessage(code); grouping itself, being client-side, has no independent failure mode.

Acceptance Criteria

  • REQ-001 — A visual-regression assertion confirms the life-event/curated-pill/world-band layers are pixel-identical across the three modes.
  • REQ-002 — Switching modes issues zero additional network requests (asserted in the E2E scenario); re-render completes within 200 ms on the loaded payload.
  • REQ-003 — In Ereignis mode, a letter whose document is in event E's documents set appears under E's cluster; the cluster count matches the number of such letters (mockup: "Briefe von der Front · 24" ⇒ 24).
  • REQ-004 — In Thema mode, a letter tagged Krieg/Briefe von der Front appears in the year bucket under root tag Krieg; counts match (e.g. "Weihnachten · 3" ⇒ 3).
  • REQ-005 — Regenerated api.d.ts shows linkedEventId?, rootTagId?, rootTagName? on the letter entry; npm run generate:api is a task; no entity graph is serialized (ADR-036 view assertion).
  • REQ-005b — The three fields are optional (nullable) in the generated TypeScript; a non-letter entry serializes them as null/absent.
  • REQ-006 — A letter with linkedEventId == null renders in the "Weitere Briefe" bucket of its year and in no event cluster.
  • REQ-007 — A letter with no tag renders in the "Ohne Thema" bucket of its year.
  • REQ-008 — A letter with ≥2 tags appears exactly once across all Thema buckets (count of its occurrences == 1).
  • REQ-009 — A tag named <img src=x onerror=alert(1)> renders as inert text; a grep assertion confirms no {@html} appears in any lib/timeline/ component (including new regroup components).
  • REQ-010 — Each of the following is asserted individually: (a) every segment exposes role="radio" inside a role="radiogroup" with aria-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.
  • REQ-011 — At 320px the control shows no horizontal overflow and each tap target stays ≥44px (Playwright viewport assertion); each abbreviated segment exposes an aria-label equal to its full word.
  • REQ-012 — All locale files contain every key (segments + abbreviations + hint + bucket labels); npm run lint (i18n check) passes with no missing-key warning.
  • REQ-013 — Simulating a failed timeline fetch shows the localized error message, not a raw code.

Out of Scope

  • Per-month drill-down (separate issue).
  • Filters (#8).
  • Any persisted letter→event FK or "primary tag" flag — the association and the primary tag are computed, by deliberate decision (see Resolved Decisions 2 & 3).
  • A server-side grouping= query param — explicitly rejected.

API / Contract Stub

GET /api/timeline — path, method, and @RequirePermission(Permission.READ_ALL) unchanged. Response TimelineEntryDTO gains three nullable fields on LETTER entries (record grows 13 → 16 components):

TimelineEntryDTO:
  ...existing 13 fields...
  linkedEventId:  UUID | null   # curated event whose `documents` contains this letter's document; null if none
  rootTagId:      UUID | null   # primary root tag id; null if letter has no tags
  rootTagName:    string | null # display name of the primary root tag (id + name only, no entity graph)

No new endpoint, no mutating path. Run npm run generate:api after the DTO change and commit the regenerated api.d.ts in 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_eventsdocuments ManyToMany (table timeline_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 is V78 — not consumed by this feature.)

Non-Functional Notes

  • New cross-domain edge: TimelineService gains a TagService dependency (it does not inject one today) to resolve the root-tag — service-to-service per §1.3, never TagRepository. TagService has no public root-tag resolver today, but TagRepository.findAncestorIds(UUID) (a recursive CTE with a depth guard) already exists; the implementer should add a public root-resolution method to TagService (e.g. Tag resolveRoot(UUID)) that wraps that CTE — one query per distinct tag, preferred over looping getById up the Tag.parentId chain — built as the batched/memoized pass below, not per-letter calls.
  • N+1 / fan-out (build deliberately in T-5, do not retrofit): mapDocument runs per-letter today. linkedEventId and rootTagId/rootTagName must be resolved in a single batched pass inside the existing TimelineService assembly transaction — one curated-event→documents map, plus one root-tag walk memoized per distinct tag (not per letter), reusing TagRepository.findAncestorIds. TimelineEvent.documents already carries @BatchSize(50). No new index required (reuses existing join keys).
  • Logging: the assembly/bucketing path logs UUIDs only — never tag names or letter titles (§2.7 PII).

Security Considerations

Read-only feature on the family-only archive; no new mutating endpoint, so no new authn/authz If clause beyond the existing READ_ALL on GET /api/timeline.

STRIDE Relevant? Mitigation
Spoofing No No new auth surface.
Tampering No No write path.
Repudiation No No state change to audit.
Information disclosure Low All family readers already see the whole timeline (READ_ALL); the enriched DTO exposes only a tag/event id + display name the reader can already see. No per-user scoping to break (no new IDOR surface).
DoS No Client-side regroup over an already-loaded payload; no new query.
Elevation No No permission boundary touched.

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 every lib/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)

  • ADR-044 written (status Proposed→Accepted) capturing both forks: client-side regroup transport + computed letter→event link. Next free number 044 (verified). — owner: implementer
  • RTM rows — mirror REQ-001..013 (incl. REQ-005b) into .specify/rtm.md with issue #827, Status=Planned. — owner: implementer
  • Regenerated api.d.ts committed in the implementation PR. — owner: implementer

Open Questions

Empty — all three resolved (see Resolved Decisions). None block implementation.

Traceability

REQ-ID Task ID(s) Test ID(s) Status
REQ-001 T-1 timeline axis-fixed layers identical across modes (visual-regression) Planned
REQ-002 T-2 regroup issues zero network requests (E2E) Planned
REQ-003 T-3 Ereignis clusters letter under event-by-document (svelte.spec) Planned
REQ-004 T-4 Thema buckets letter under root tag (svelte.spec) Planned
REQ-005 T-5 DTO carries linkedEventId/rootTagId/rootTagName, batched assembly (backend) Planned
REQ-005b T-5 three fields nullable / not REQUIRED in generated types Planned
REQ-006 T-6 unlinked letter → "Weitere Briefe" bucket Planned
REQ-007 T-7 untagged letter → "Ohne Thema" bucket Planned
REQ-008 T-8 multi-tag letter appears exactly once Planned
REQ-009 T-9 tag name with markup renders inert; no {@html} grep Planned
REQ-010 T-10 grouping control a11y clauses (a–g) + dark-mode axe (axe + E2E) Planned
REQ-011 T-11 320px control no overflow + aria-label full word Planned
REQ-012 T-12 all i18n keys (segments + abbrev + hint + bucket labels) in de/en/es Planned
REQ-013 T-13 failed fetch shows localized error Planned

(After approval, mirror one row per REQ-NNN into .specify/rtm.md with issue #827 — see Pre-Implementation Artifacts.)

Persona Review Results

Persona Status Key Findings Resolved
Requirements Engineer APPROVE REQ-010 AC asserts each a11y clause (a–g) individually; RTM mirroring tracked in Pre-Implementation Artifacts REQ-010 AC, Pre-Implementation Artifacts
Developer APPROVE Root-tag resolution: add a public TagService.resolveRoot wrapping the existing TagRepository.findAncestorIds CTE, built as the batched/memoized pass NFR (TagService root-resolver)
Security APPROVE {...}-escaping (REQ-009) with XSS payload AC + {@html} grep; read-only; STRIDE; UUID-only logging REQ-009, Security Considerations
DevOps APPROVE No migration/rollback/ordering; additive-nullable rolling-deploy-safe; commit api.d.ts in PR Data Model = none; API stub note
UI/UX APPROVE 320px abbreviations carry aria-label full word (REQ-011) + abbrev i18n keys (REQ-012); dark-mode contrast tested REQ-011, REQ-012
Architect APPROVE ADR-044 records both forks; reuses ADR-040 join (@BatchSize(50)) + ADR-036 view; N+1 bounded; TagService edge Pre-Implementation Artifacts, NFR

Four 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).

# 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 `TimelineEntryDTO` does 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`): - §1.6 (lazy-collection views / ADR-036) — the new DTO fields are assembled in-transaction in the timeline view; no lazy entity collection is serialized. - §1.3 (cross-domain access via services) — Thema's root-tag lookup goes through the **tag domain's service**, never a `TimelineService`→tag-repo reach. - §2.5 (default Svelte `{...}` escaping, no `{@html}`) — tag-name chips render curator-authored text. Related: follow-up to #779; reuses the #779 `lib/timeline/` component tree, the `TimelineEntryDTO` contract, 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): 1. **Grouping transport → client-side regroup over an enriched DTO.** No `grouping=` query param. `GET /api/timeline` already returns the full timeline in one payload; regrouping is an in-memory presentation transform in `lib/timeline/`. Rejected server-side `grouping=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. 2. **Letter → curated-event link → computed, reusing the existing `timeline_events`↔`documents` ManyToMany.** A `TimelineEvent` already has a ManyToMany `documents` set (ADR-040; join table `timeline_event_documents`, `@BatchSize(50)`). A letter clusters under a curated event iff that event's `documents` contains 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). 3. **Primary root tag for a multi-tagged letter → deterministic first-by-order: the root ancestor of the letter's alphabetically-first tag name** (walking `Tag.parentId` to 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 - **REQ-001** (Ubiquitous) — The `/zeitstrahl` view 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. - **REQ-002** (Event-driven) — When the user selects a grouping segment (Datum/Ereignis/Thema), the timeline shall re-bundle loose letters client-side from the already-loaded payload **without** re-fetching `GET /api/timeline`. - **REQ-003** (State-driven) — While in **Ereignis** mode, the view shall cluster each loose letter under the curated `TimelineEvent` whose `documents` set contains that letter's document. - **REQ-004** (State-driven) — While in **Thema** mode, the view shall bucket each loose letter, per year, under its primary root tag (root ancestor of its alphabetically-first tag). - **REQ-005** (Ubiquitous) — The `TimelineEntryDTO` shall carry, for `LETTER` entries, a nullable `linkedEventId` (UUID), `rootTagId` (UUID) and `rootTagName` (String), assembled in-transaction in `TimelineService` (ADR-036). - **REQ-005b** (Ubiquitous) — The three new DTO fields shall **not** be marked `@Schema(requiredMode = REQUIRED)` (they are null for non-letter entries and for letters with no event/tag). - **REQ-006** (Unwanted-behavior) — If a loose letter in Ereignis mode has no linked curated event (`linkedEventId == null`), then the view shall place it in a per-year "Weitere Briefe" bucket (fall back, never hide). - **REQ-007** (Unwanted-behavior) — If a loose letter in Thema mode has no tag (`rootTagId == null`), then the view shall place it in a per-year "Ohne Thema" bucket. - **REQ-008** (Unwanted-behavior) — If a loose letter has multiple tags, then the view shall display it under exactly one root tag (the deterministic primary per REQ-004), never duplicated across buckets. - **REQ-009** (Ubiquitous) — The view shall render tag names and the multi-tag hint through Svelte default `{...}` escaping and shall never use `{@html}` for curator-authored text (CWE-79). - **REQ-010** (Ubiquitous) — The grouping control shall be a keyboard-navigable `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. - **REQ-011** (State-driven) — While the viewport is ≤320px wide, the grouping control shall remain fully visible and operable (no overflow, tap target ≥44px) via abbreviated/stacked labels, each abbreviation carrying its full word as an `aria-label` so the screen-reader announcement stays unambiguous. - **REQ-012** (Ubiquitous) — The segment labels (Datum/Ereignis/Thema), their ≤320px abbreviations, the multi-tag hint, the bucket labels ("Weitere Briefe", "Ohne Thema"), and any count/label strings shall exist as Paraglide keys in `messages/de.json`, `messages/en.json`, and `messages/es.json` — no German literal in markup. - **REQ-013** (Optional-feature) — Where `GET /api/timeline` data fails to load, the view shall surface the existing localized error state via `getErrorMessage(code)`; grouping itself, being client-side, has no independent failure mode. ## Acceptance Criteria - **REQ-001** — A visual-regression assertion confirms the life-event/curated-pill/world-band layers are pixel-identical across the three modes. - **REQ-002** — Switching modes issues **zero** additional network requests (asserted in the E2E scenario); re-render completes within 200 ms on the loaded payload. - **REQ-003** — In Ereignis mode, a letter whose document is in event E's `documents` set appears under E's cluster; the cluster count matches the number of such letters (mockup: "Briefe von der Front · 24" ⇒ 24). - **REQ-004** — In Thema mode, a letter tagged `Krieg/Briefe von der Front` appears in the year bucket under root tag `Krieg`; counts match (e.g. "Weihnachten · 3" ⇒ 3). - **REQ-005** — Regenerated `api.d.ts` shows `linkedEventId?`, `rootTagId?`, `rootTagName?` on the letter entry; `npm run generate:api` is a task; no entity graph is serialized (ADR-036 view assertion). - **REQ-005b** — The three fields are optional (nullable) in the generated TypeScript; a non-letter entry serializes them as null/absent. - **REQ-006** — A letter with `linkedEventId == null` renders in the "Weitere Briefe" bucket of its year and in no event cluster. - **REQ-007** — A letter with no tag renders in the "Ohne Thema" bucket of its year. - **REQ-008** — A letter with ≥2 tags appears exactly once across all Thema buckets (count of its occurrences == 1). - **REQ-009** — A tag named `<img src=x onerror=alert(1)>` renders as inert text; a grep assertion confirms no `{@html}` appears in **any** `lib/timeline/` component (including new regroup components). - **REQ-010** — Each of the following is asserted individually: (a) every segment exposes `role="radio"` inside a `role="radiogroup"` with `aria-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. - **REQ-011** — At 320px the control shows no horizontal overflow and each tap target stays ≥44px (Playwright viewport assertion); each abbreviated segment exposes an `aria-label` equal to its full word. - **REQ-012** — All locale files contain every key (segments + abbreviations + hint + bucket labels); `npm run lint` (i18n check) passes with no missing-key warning. - **REQ-013** — Simulating a failed timeline fetch shows the localized error message, not a raw code. ## Out of Scope - Per-month drill-down (separate issue). - Filters (#8). - Any persisted letter→event FK or "primary tag" flag — the association and the primary tag are **computed**, by deliberate decision (see Resolved Decisions 2 & 3). - A server-side `grouping=` query param — explicitly rejected. ## API / Contract Stub `GET /api/timeline` — path, method, and `@RequirePermission(Permission.READ_ALL)` **unchanged**. Response `TimelineEntryDTO` gains three nullable fields on `LETTER` entries (record grows 13 → 16 components): ``` TimelineEntryDTO: ...existing 13 fields... linkedEventId: UUID | null # curated event whose `documents` contains this letter's document; null if none rootTagId: UUID | null # primary root tag id; null if letter has no tags rootTagName: string | null # display name of the primary root tag (id + name only, no entity graph) ``` No new endpoint, no mutating path. Run `npm run generate:api` after the DTO change and **commit the regenerated `api.d.ts` in 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`↔`documents` ManyToMany (table `timeline_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 is `V78` — not consumed by this feature.) ## Non-Functional Notes - **New cross-domain edge:** `TimelineService` gains a `TagService` dependency (it does not inject one today) to resolve the root-tag — service-to-service per §1.3, **never** `TagRepository`. `TagService` has no public root-tag resolver today, but `TagRepository.findAncestorIds(UUID)` (a recursive CTE with a depth guard) already exists; the implementer should add a public root-resolution method to `TagService` (e.g. `Tag resolveRoot(UUID)`) that wraps that CTE — one query per distinct tag, **preferred** over looping `getById` up the `Tag.parentId` chain — built as the batched/memoized pass below, not per-letter calls. - **N+1 / fan-out (build deliberately in T-5, do not retrofit):** `mapDocument` runs per-letter today. `linkedEventId` and `rootTagId/rootTagName` must be resolved in a **single batched pass** inside the existing `TimelineService` assembly transaction — one curated-event→documents map, plus one root-tag walk **memoized per distinct tag** (not per letter), reusing `TagRepository.findAncestorIds`. `TimelineEvent.documents` already carries `@BatchSize(50)`. No new index required (reuses existing join keys). - **Logging:** the assembly/bucketing path logs UUIDs only — never tag names or letter titles (§2.7 PII). ## Security Considerations Read-only feature on the family-only archive; no new mutating endpoint, so no new authn/authz `If` clause beyond the existing `READ_ALL` on `GET /api/timeline`. | STRIDE | Relevant? | Mitigation | |---|---|---| | Spoofing | No | No new auth surface. | | Tampering | No | No write path. | | Repudiation | No | No state change to audit. | | Information disclosure | Low | All family readers already see the whole timeline (READ_ALL); the enriched DTO exposes only a tag/event id + display name the reader can already see. No per-user scoping to break (no new IDOR surface). | | DoS | No | Client-side regroup over an already-loaded payload; no new query. | | Elevation | No | No permission boundary touched. | 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 every `lib/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) - [ ] **ADR-044** written (status Proposed→Accepted) capturing both forks: client-side regroup transport + computed letter→event link. Next free number 044 (verified). — owner: implementer - [ ] **RTM rows** — mirror REQ-001..013 (incl. REQ-005b) into `.specify/rtm.md` with issue #827, Status=Planned. — owner: implementer - [ ] **Regenerated `api.d.ts`** committed in the implementation PR. — owner: implementer ## Open Questions > Empty — all three resolved (see Resolved Decisions). None block implementation. ## Traceability | REQ-ID | Task ID(s) | Test ID(s) | Status | |---|---|---|---| | REQ-001 | T-1 | timeline axis-fixed layers identical across modes (visual-regression) | Planned | | REQ-002 | T-2 | regroup issues zero network requests (E2E) | Planned | | REQ-003 | T-3 | Ereignis clusters letter under event-by-document (svelte.spec) | Planned | | REQ-004 | T-4 | Thema buckets letter under root tag (svelte.spec) | Planned | | REQ-005 | T-5 | DTO carries linkedEventId/rootTagId/rootTagName, batched assembly (backend) | Planned | | REQ-005b | T-5 | three fields nullable / not REQUIRED in generated types | Planned | | REQ-006 | T-6 | unlinked letter → "Weitere Briefe" bucket | Planned | | REQ-007 | T-7 | untagged letter → "Ohne Thema" bucket | Planned | | REQ-008 | T-8 | multi-tag letter appears exactly once | Planned | | REQ-009 | T-9 | tag name with markup renders inert; no `{@html}` grep | Planned | | REQ-010 | T-10 | grouping control a11y clauses (a–g) + dark-mode axe (axe + E2E) | Planned | | REQ-011 | T-11 | 320px control no overflow + aria-label full word | Planned | | REQ-012 | T-12 | all i18n keys (segments + abbrev + hint + bucket labels) in de/en/es | Planned | | REQ-013 | T-13 | failed fetch shows localized error | Planned | (After approval, mirror one row per REQ-NNN into `.specify/rtm.md` with issue #827 — see Pre-Implementation Artifacts.) ## Persona Review Results | Persona | Status | Key Findings | Resolved | |---|---|---|---| | Requirements Engineer | APPROVE | REQ-010 AC asserts each a11y clause (a–g) individually; RTM mirroring tracked in Pre-Implementation Artifacts | REQ-010 AC, Pre-Implementation Artifacts | | Developer | APPROVE | Root-tag resolution: add a public `TagService.resolveRoot` wrapping the existing `TagRepository.findAncestorIds` CTE, built as the batched/memoized pass | NFR (TagService root-resolver) | | Security | APPROVE | `{...}`-escaping (REQ-009) with XSS payload AC + `{@html}` grep; read-only; STRIDE; UUID-only logging | REQ-009, Security Considerations | | DevOps | APPROVE | No migration/rollback/ordering; additive-nullable rolling-deploy-safe; commit api.d.ts in PR | Data Model = none; API stub note | | UI/UX | APPROVE | 320px abbreviations carry `aria-label` full word (REQ-011) + abbrev i18n keys (REQ-012); dark-mode contrast tested | REQ-011, REQ-012 | | Architect | APPROVE | ADR-044 records both forks; reuses ADR-040 join (`@BatchSize(50)`) + ADR-036 view; N+1 bounded; TagService edge | Pre-Implementation Artifacts, NFR | _Four 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`)._
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-13 19:12:49 +02:00
marcel added the P2-mediumfeatureneeds-discussionui labels 2026-06-13 19:13:22 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#827