Timeline: global /zeitstrahl view #779

Open
opened 2026-06-07 19:29:22 +02:00 by marcel · 8 comments
Owner

Milestone: Zeitstrahl — Family Timeline
Spec: docs/superpowers/specs/2026-06-07-family-timeline-design.md § "Concept & UX" / "Frontend"
Depends on (hard, must be merged first):

  • Issue 5 — backend GET /api/timelineTimelineDTO assembly endpoint (year-bucketing, precision sort, undated bucket).
  • Issue 6dateLabel.ts shared precision→label helper + regenerated API types (TimelineDTO/TimelineYearDTO/TimelineEntryDTO in $lib/generated/api).

This issue is blocked until both 5 and 6 are merged — neither the route, the frontend/src/lib/timeline/ dir, nor the generated DTO types exist yet.

Context

The primary reader surface: a year-banded vertical timeline, phone-first for the younger audience. A long-lived navigable spine that weaves three layers (derived person life-events, hand-curated events, letters) into one chronological view.

Scope

Route /zeitstrahl rendering the three layers per year band, with an "Ohne Datum" section at the end. Global view only (personId undefined). Issue 10 reuses the same TimelineView with personId set; issue 8 adds filters; issue 9 adds curator forms — all out of scope here.

Non-goals (this issue)

  • Letter-cluster-under-event is deferred (see Decisions). In this global view every letter renders once, in its own year band. EventCard does not render child LetterCards here.
  • TimelineFilters.svelte — issue 8.
  • Jump-to-year rail / E2E axe gate — issue 11.
  • Re-implementing precision rendering — consume dateLabel.ts from issue 6; never add a second formatPrecision-style function in timeline/.
  • ADR-035 is authored as part of issue 2 (the timeline/ domain ADR belongs with the backend domain introduction). This issue references the ADR but does not write it.

Data flow (mandatory)

  • SSR-first. /zeitstrahl/+page.server.ts loads /api/timeline server-side via createApiClient(fetch) so the auth cookie is forwarded. No client-side fetch/onMount.
  • Follow the exact load pattern from geschichten/+page.server.ts:
    const api = createApiClient(fetch);
    const result = await api.GET('/api/timeline');
    if (!result.response.ok) throw error(result.response.status, getErrorMessage(extractErrorCode(result.error)));
    return { timeline: result.data! };
    
  • GET /api/timeline is session-authenticated (any authenticated user). This issue touches no write endpoint, no @RequirePermission, no new ErrorCode.
  • On non-ok, map via getErrorMessage(extractErrorCode(result.error)) — never render raw backend JSON.
  • TimelineView is presentation-only. All merge/sort/bucket logic lives in the issue-5 backend; the client never re-sorts or re-buckets. If it has to, that's a sign the issue-5 DTO contract is wrong — fix it there.
  • The component renders entries in DTO order — no re-sorting client-side. This is explicit: the component is a pass-through, proven by the AC-ORDER test.
  • No geschichte/ imports — keep the domain boundary clean. Reuse from document/ (the lib/document/timeline.ts tick helpers, links to /documents/[id]) is fine.

Component design

Four components under frontend/src/lib/timeline/ (one nameable region each):

  • TimelineView.svelte — orchestrator. Holds the data and an optional personId prop (build it in now, undefined for global; issue 10 is then a no-op wire-up). Declare as let { personId = undefined }: { personId?: string } = $props(). Do not pass personId down to YearBand or EventCard — they are leaf cards with no scope awareness. Renders year bands newest/oldest per the issue-5 DTO order, then the undated section. Use $derived (never $effect) for any view-derived state. The empty-state check is the first thing in the template: {#if yearBands.length === 0 && undated.length === 0}.
  • YearBand.svelte — one <section> with the year as a real <h2> heading and its entries. The year heading is sticky (position: sticky / sticky top-…) so the spine stays oriented on a long phone scroll (see Decisions).
  • EventCard.svelte — a leaf card for a PERSONAL/HISTORICAL/derived event. Do not branch internally into three giant blocks; use a $derived accent-class map keyed on (type, derived) so the template stays declarative. Extract the accent-class derivation as a module-level const map or a co-located eventCardConfig.ts helper — keep EventCard.svelte under 60 lines; if it exceeds 60 lines with the glyph/icon/label matrix, split into a getAccentConfig(entry) helper imported from eventCardConfig.ts.
  • LetterCard.svelte — compact letter row: sender → receiver, snippet/title, precision date. Links to /documents/[id] via an internal <a href="/documents/{documentId}"> built from the DTO's documentId (UUID) — never from any free-text field; no target="_blank". Add min-h-[44px] flex items-center to the <a> for 44px WCAG 2.2 touch target compliance on small screens.

Font split within cards

  • Sender name, receiver name, event/letter titles → font-serif (Tinos).
  • The arrow/separator between sender and receiver, date labels, metadata chrome → font-sans.
    Apply this split at the element level within LetterCard and EventCard, not just at the card level.

Keying

  • Year bands keyed by (year.year).
  • Entries mix events and letters → key on a composite (entry.kind + ':' + (entry.eventId ?? entry.documentId)) to avoid UUID-space collisions.

Honest precision rendering

Every dated item (events and letters) renders through the shared dateLabel.ts (issue 6): 28. Juli 1914 (DAY), Juli 1914 (MONTH), Sommer 1914 (SEASON), 1914 (YEAR), ca. 1914 (APPROX), 1914–1918 (RANGE), Ohne Datum (UNKNOWN). Never fabricate a day. A RANGE shows a visible pill marker (e.g. 1914–1918 pill with aria-label="Zeitraum: 1914 bis 1918") — not a vertical rule (a rule is visual-only; a pill with text is both visible and readable by screen readers). When precision === 'RANGE' but eventDateEnd is null (data integrity edge case), render without the span marker — no crash, no pill, just the start year label.

Visual-accent matrix (redundant cues — WCAG 1.4.1, never color-alone)

Each layer carries an icon/glyph and a text label in addition to the accent color:

State Accent Redundant cue
HISTORICAL muted/world gray accent globe/world icon + label "Weltgeschehen"
PERSONAL — curated family/navy accent person icon + label "Familie"
PERSONAL — derived (birth/death/marriage) family/navy accent distinct glyph: ✳ Geburt / † Tod / ⚭ Heirat

Every accent is a semantic token remapped per theme; verify AA contrast in both light and dark mode (muted "historical" gray is the likeliest dark-mode AA failure — verify computed color on the EventCard HISTORICAL accent against its background: 3:1 for large text, 4.5:1 for normal text).

Accessible glyph markup

Unicode glyphs (✳ † ⚭) are not self-announcing to screen readers. Wrap each as:

<span aria-hidden="true"></span><span class="sr-only">Tod</span>

Never render a bare Unicode glyph without adjacent sr-only text. The sr-only text uses the German label (see i18n decision below).

Styling & layout

  • Card pattern: rounded-sm border border-line bg-surface shadow-sm p-6.
  • Year/section headings: text-xs font-bold uppercase tracking-widest text-ink-3.
  • font-serif (Tinos) for names and event/letter titles; font-sans for date labels and metadata chrome. Body text ≥ 16px (text-base) for senior readers — prefer text-lg (18px) for description/snippet text. Never text-sm for body content.
  • Mobile-first single column. Responsive floor is 320px (test there, not only 375px). The sender → receiver letter row must flex-wrap with min-w-0 so long names wrap instead of pushing the card past the viewport — no horizontal overflow.
  • LetterCard link: visible focus-visible:ring-2 focus-visible:ring-brand-navy, min-h-[44px] flex items-center (≥44px touch target).
  • Use the shared <BackButton> from $lib/shared/primitives/BackButton.svelte.
  • Dark-mode tokens throughout.

Mandatory states (reliability)

  • Empty: zero years + zero undated → an empty-state message (timeline.empty_state i18n key, German: "Noch keine Ereignisse"), never a blank page. Placed inside a meaningful landmark (inside <main>) with no nested conditions.
  • Undated section uses {#if undated.length > 0} — removes the section from the DOM entirely when empty (not aria-hidden). A <section> with <h2>Ohne Datum</h2> when present.
  • Loading/error: handled by the SSR load + SvelteKit error boundary via the mapped message above.
  • No {@html} anywhere in this component tree — render free text (description, title, names) with plain {...} interpolation; for line breaks use whitespace-pre-line CSS, not {@html}.

i18n

Add keys to messages/{de,en,es}.json. German is primary; labels for derived events are German-only across all locales for MVP (see Decisions). The full key list:

Key de en es
timeline.heading Zeitstrahl Timeline Línea de tiempo
timeline.empty_state Noch keine Ereignisse No events yet Aún no hay eventos
timeline.undated_section Ohne Datum Without Date Sin Fecha
timeline.layer.world Weltgeschehen Weltgeschehen Weltgeschehen
timeline.layer.family Familie Familie Familie
timeline.derived.birth Geburt Geburt Geburt
timeline.derived.death Tod Tod Tod
timeline.derived.marriage Heirat Heirat Heirat

The EN/ES columns intentionally carry the German value for the derived-event and layer labels — this is a documented MVP decision, not an oversight.

Required doc updates (blockers at PR time)

  • CLAUDE.md route table — add /zeitstrahl.
  • docs/architecture/c4/l3-frontend-*.puml — add the /zeitstrahl route + lib/timeline/ dir.
  • docs/architecture/db/db-orm.puml and db-relationships.puml — the new timeline_events, timeline_event_persons, timeline_event_documents tables must appear (these tables are introduced in issue 2/5; confirm they are present before this PR merges).
  • Frontend structure docs — note the new frontend/src/lib/timeline/ domain dir.
  • GLOSSARY.md — add entries for: "Zeitstrahl", "TimelineEvent", "EventType (PERSONAL/HISTORICAL)", "derived event", "Lebensweg".
  • ADR-035 — authored as part of issue 2; confirm it exists before this PR merges.

Acceptance criteria

  • AC-RENDER: Years render top-to-bottom (one <section> + <h2> per band); each band shows its events + letters; the undated section is present when there are undated items.
  • AC-ORDER: Given a band with a DAY-precision letter (1923-04-12) and a YEAR-precision letter (1923), the DAY item appears above the YEAR item. (Ordering comes from the issue-5 DTO; the component renders in DTO order — no client-side re-sorting.)
  • AC-SAME-PRECISION-ORDER: Given two entries with identical precision and year, they render in the order the DTO delivers them — the component is a pass-through, not a sorter.
  • AC-RANGE: Given a RANGE event 1914–1918, it appears once, in the 1914 band, with a visible pill span marker — not repeated in 1915–1918.
  • AC-RANGE-NULL: Given precision === 'RANGE' with eventDateEnd null, EventCard renders without a span marker and does not crash.
  • AC-UNDATED: Given items with UNKNOWN precision, they appear only in the "Ohne Datum" section at the end, and that section is absent from the DOM when empty ({#if} block, not aria-hidden).
  • AC-EMPTY: Given no events and no letters, /zeitstrahl renders an empty-state message (timeline.empty_state), not a blank page.
  • AC-PERSON-ID-PROP: When personId is undefined, TimelineView renders the global timeline (all years and entries from the DTO) with no filtering — confirms the prop is a no-op when unset.
  • AC-DERIVED-STYLE: Each of {HISTORICAL, PERSONAL-curated, PERSONAL-derived} renders its accent and its redundant non-color cue per the matrix above. Derived-event glyphs (✳ † ⚭) are wrapped with aria-hidden="true" + adjacent sr-only label text.
  • AC-LINK: LetterCard renders an internal link whose href is exactly /documents/{documentId}.
  • AC-TOUCH: LetterCard link has min-h-[44px] applied (≥44px touch target).
  • AC-RESPONSIVE: Renders with no horizontal overflow at 320px — verified via Playwright E2E (frontend/e2e/zeitstrahl.spec.ts, see Tests).
  • AC-A11Y: Each year band and the undated bucket is a <section> with a heading; the whole timeline forms a navigable heading list; year headings are sticky. Verified manually pre-merge; automated axe gate deferred to issue 11.

Tests (TDD)

Component tests — frontend/src/lib/timeline/TimelineView.svelte.spec.ts

(*.svelte.spec.ts, vitest-browser-svelte, real DOM, assert via getByRole/getByText):

  • renders one section per year with year headings as headings;
  • renders an EventCard and a LetterCard within the correct band;
  • AC-ORDER: DAY-precision entry before YEAR-precision entry within a band (DTO order preserved);
  • AC-SAME-PRECISION-ORDER: two YEAR-precision entries in same band render in DTO order;
  • AC-RANGE: RANGE event appears once in its start band with a pill span marker;
  • AC-RANGE-NULL: RANGE event with null eventDateEnd renders without pill, no crash;
  • AC-UNDATED: undated entries in the "Ohne Datum" section; section absent from DOM when no undated items;
  • AC-EMPTY: zero years + zero undated → empty message (timeline.empty_state text visible);
  • AC-PERSON-ID-PROP: personId undefined renders global timeline; no prop mutation;
  • AC-DERIVED-STYLE: each layer shows its redundant cue (text label visible via getByText); glyph wrapped with sr-only sibling;
  • AC-LINK: LetterCard href is /documents/{id};
  • AC-TOUCH: LetterCard link element has min-h-[44px] class.

Use a makeTimelineDTO(overrides) / makeEntry(overrides) factory — don't hand-build the nested DTO in each test. Place the factory in frontend/src/lib/timeline/test-factories.ts so it is importable from both this spec and future issue-10 tests. Minimum factory shape:

makeTimelineDTO({ years: [...], undated: [...] })
makeEntry({ kind: 'LETTER', precision: 'DAY', documentId: '...', year: 1923 })
makeEntry({ kind: 'EVENT', type: 'HISTORICAL', precision: 'RANGE', eventDateEnd: 1918 })

Run targeted single-file locally (--project=client for the .svelte.spec.ts); leave the full sweep to CI.

Server load test — frontend/src/routes/zeitstrahl/page.server.test.ts

(node, plain TS, mock createApiClientfilename: page.server.test.ts, not +page.server.test.ts, matching codebase convention in stammbaum/page.server.test.ts):

  • load returns { timeline: data } on ok;
  • throws error(404, mappedMessage) on 404 non-ok;
  • throws error(500, mappedMessage) on 500 non-ok;
  • throws/redirects on 401 (session expiry → /login pattern, matching stammbaum/page.server.test.ts lines 63–69);
  • throws error(403, mappedMessage) on 403 — ensures READ_ALL-less sessions produce a user-friendly message, not raw JSON.

dateLabel.ts precision rendering is tested in issue 6 — here, only assert the card renders the consumed label (e.g. render a LetterCard with precision: 'MONTH' and assert the label text contains the MONTH output from dateLabel.ts).

E2E test — frontend/e2e/zeitstrahl.spec.ts (Playwright)

  • AC-RESPONSIVE: page.setViewportSize({ width: 320, height: 812 }), navigate to /zeitstrahl, assert await page.evaluate(() => document.body.scrollWidth) === 320 (no horizontal overflow).

Decisions resolved (Round 0 — from original spec)

  • Letter-cluster-under-event in the global view → DEFERRED. This issue places every letter once, in its own band; EventCard renders no child letters. Clustering is curator-driven (linking in issue 9) and most meaningful in the per-person view (issue 10) — shipping the simplest correct rendering first keeps EventCard/LetterCard as flat leaf cards and avoids speculative composition.
  • Spine affordance → sticky per-year heading now; jump-to-year rail deferred to issue 11. position: sticky on the <h2> year heading is near-free and solves long-scroll orientation on phones; combined with semantic <section>/<h2> (screen-reader heading navigation) it covers the MVP.

Decisions resolved (Round 1 — from review)

  • DatePrecision package ownership → keep in document/, import across domain boundary (Option A). The enum is shared vocabulary — importing it from document/ in timeline/ is a bounded, read-only, non-cyclic dependency. Promoting to shared/ would touch Document, TranscriptionBlock, and all importers for a marginal structural benefit. KISS wins. Documented in ADR-035 (issue 2) so a future architect understands the deliberate trade-off.
  • ADR-035 → written as part of issue 2, not this issue; confirmed present before this PR merges. The architecture decision belongs with the backend domain introduction (issue 2), not the frontend route. This issue's PR checklist requires ADR-035 to exist before merge.
  • Derived-event glyph label language → German-only for MVP (Option A). The archive's primary audience and family context is German; glyph labels (Geburt/Tod/Heirat) are proper German nouns universally understood in this context. EN/ES locales carry the German value intentionally — documented in the i18n table above as a deliberate MVP choice, not an oversight. Adding 6 translation strings buys negligible value at current scale.
  • "Ohne Datum" section visibility → {#if undated.length > 0} (removes from DOM). Not aria-hidden. Removing from the DOM is cleaner for screen readers — hidden content behind aria-hidden still occupies the accessibility tree and can confuse navigation. {#if} is the idiomatic Svelte pattern.
  • i18n key for empty state → timeline.empty_state. Matches the scoped key naming convention used elsewhere (timeline.* namespace). German value: "Noch keine Ereignisse."
  • RANGE event with null eventDateEnd → render without span marker, no crash. Graceful degradation: the component silently omits the pill. Backend validation is not guaranteed to prevent this edge case; crashing here would produce a blank timeline page.
  • Page server test filename → page.server.test.ts (no + prefix). Matches the established codebase convention (stammbaum/page.server.test.ts).
  • AC-RESPONSIVE → Playwright E2E spec, not vitest-browser. Layout overflow at 320px is not assertable in vitest-browser-svelte (no real viewport control). The component test asserts flex-wrap/min-w-0 class presence as a surrogate; the real overflow assertion lives in frontend/e2e/zeitstrahl.spec.ts with page.setViewportSize.
**Milestone:** Zeitstrahl — Family Timeline **Spec:** `docs/superpowers/specs/2026-06-07-family-timeline-design.md` § "Concept & UX" / "Frontend" **Depends on (hard, must be merged first):** - **Issue 5** — backend `GET /api/timeline` → `TimelineDTO` assembly endpoint (year-bucketing, precision sort, undated bucket). - **Issue 6** — `dateLabel.ts` shared precision→label helper + regenerated API types (`TimelineDTO`/`TimelineYearDTO`/`TimelineEntryDTO` in `$lib/generated/api`). This issue is **blocked** until both 5 and 6 are merged — neither the route, the `frontend/src/lib/timeline/` dir, nor the generated DTO types exist yet. ## Context The primary reader surface: a year-banded vertical timeline, **phone-first** for the younger audience. A long-lived navigable spine that weaves three layers (derived person life-events, hand-curated events, letters) into one chronological view. ## Scope Route `/zeitstrahl` rendering the three layers per year band, with an "Ohne Datum" section at the end. **Global view only** (`personId` undefined). Issue 10 reuses the same `TimelineView` with `personId` set; issue 8 adds filters; issue 9 adds curator forms — all out of scope here. ### Non-goals (this issue) - **Letter-cluster-under-event is deferred** (see Decisions). In this global view every letter renders **once**, in its own year band. `EventCard` does **not** render child `LetterCard`s here. - `TimelineFilters.svelte` — issue 8. - Jump-to-year rail / E2E axe gate — issue 11. - Re-implementing precision rendering — **consume** `dateLabel.ts` from issue 6; never add a second `formatPrecision`-style function in `timeline/`. - ADR-035 is authored as part of **issue 2** (the `timeline/` domain ADR belongs with the backend domain introduction). This issue references the ADR but does not write it. ## Data flow (mandatory) - **SSR-first.** `/zeitstrahl/+page.server.ts` loads `/api/timeline` server-side via `createApiClient(fetch)` so the auth cookie is forwarded. **No** client-side `fetch`/`onMount`. - Follow the exact load pattern from `geschichten/+page.server.ts`: ```ts const api = createApiClient(fetch); const result = await api.GET('/api/timeline'); if (!result.response.ok) throw error(result.response.status, getErrorMessage(extractErrorCode(result.error))); return { timeline: result.data! }; ``` - `GET /api/timeline` is session-authenticated (any authenticated user). This issue touches **no** write endpoint, no `@RequirePermission`, no new `ErrorCode`. - On non-ok, map via `getErrorMessage(extractErrorCode(result.error))` — never render raw backend JSON. - **`TimelineView` is presentation-only.** All merge/sort/bucket logic lives in the issue-5 backend; the client never re-sorts or re-buckets. If it has to, that's a sign the issue-5 DTO contract is wrong — fix it there. - **The component renders entries in DTO order — no re-sorting client-side.** This is explicit: the component is a pass-through, proven by the AC-ORDER test. - **No `geschichte/` imports** — keep the domain boundary clean. Reuse from `document/` (the `lib/document/timeline.ts` tick helpers, links to `/documents/[id]`) is fine. ## Component design Four components under `frontend/src/lib/timeline/` (one nameable region each): - **`TimelineView.svelte`** — orchestrator. Holds the data and an **optional `personId` prop** (build it in now, undefined for global; issue 10 is then a no-op wire-up). Declare as `let { personId = undefined }: { personId?: string } = $props()`. Do **not** pass `personId` down to `YearBand` or `EventCard` — they are leaf cards with no scope awareness. Renders year bands newest/oldest per the issue-5 DTO order, then the undated section. Use `$derived` (never `$effect`) for any view-derived state. The empty-state check is the **first** thing in the template: `{#if yearBands.length === 0 && undated.length === 0}`. - **`YearBand.svelte`** — one `<section>` with the year as a real `<h2>` heading and its entries. The year heading is **sticky** (`position: sticky` / `sticky top-…`) so the spine stays oriented on a long phone scroll (see Decisions). - **`EventCard.svelte`** — a leaf card for a PERSONAL/HISTORICAL/derived event. Do **not** branch internally into three giant blocks; use a `$derived` accent-class map keyed on `(type, derived)` so the template stays declarative. Extract the accent-class derivation as a module-level const map or a co-located `eventCardConfig.ts` helper — keep `EventCard.svelte` under 60 lines; if it exceeds 60 lines with the glyph/icon/label matrix, split into a `getAccentConfig(entry)` helper imported from `eventCardConfig.ts`. - **`LetterCard.svelte`** — compact letter row: sender → receiver, snippet/title, precision date. Links to `/documents/[id]` via an internal `<a href="/documents/{documentId}">` built from the DTO's `documentId` (UUID) — never from any free-text field; no `target="_blank"`. Add `min-h-[44px] flex items-center` to the `<a>` for 44px WCAG 2.2 touch target compliance on small screens. ### Font split within cards - Sender name, receiver name, event/letter titles → `font-serif` (Tinos). - The arrow/separator between sender and receiver, date labels, metadata chrome → `font-sans`. Apply this split at the element level within `LetterCard` and `EventCard`, not just at the card level. ### Keying - Year bands keyed by `(year.year)`. - Entries mix events and letters → key on a composite `(entry.kind + ':' + (entry.eventId ?? entry.documentId))` to avoid UUID-space collisions. ### Honest precision rendering Every dated item (events and letters) renders through the shared `dateLabel.ts` (issue 6): `28. Juli 1914` (DAY), `Juli 1914` (MONTH), `Sommer 1914` (SEASON), `1914` (YEAR), `ca. 1914` (APPROX), `1914–1918` (RANGE), `Ohne Datum` (UNKNOWN). Never fabricate a day. A `RANGE` shows a **visible pill marker** (e.g. `1914–1918` pill with `aria-label="Zeitraum: 1914 bis 1918"`) — not a vertical rule (a rule is visual-only; a pill with text is both visible and readable by screen readers). When `precision === 'RANGE'` but `eventDateEnd` is null (data integrity edge case), render without the span marker — no crash, no pill, just the start year label. ## Visual-accent matrix (redundant cues — WCAG 1.4.1, never color-alone) Each layer carries an icon/glyph **and** a text label in addition to the accent color: | State | Accent | Redundant cue | |---|---|---| | HISTORICAL | muted/world gray accent | globe/world icon + label "Weltgeschehen" | | PERSONAL — curated | family/navy accent | person icon + label "Familie" | | PERSONAL — derived (birth/death/marriage) | family/navy accent | distinct glyph: ✳ Geburt / † Tod / ⚭ Heirat | Every accent is a **semantic token remapped per theme**; verify AA contrast in **both** light and dark mode (muted "historical" gray is the likeliest dark-mode AA failure — verify computed `color` on the `EventCard` HISTORICAL accent against its background: 3:1 for large text, 4.5:1 for normal text). ### Accessible glyph markup Unicode glyphs (✳ † ⚭) are **not** self-announcing to screen readers. Wrap each as: ```svelte <span aria-hidden="true">†</span><span class="sr-only">Tod</span> ``` Never render a bare Unicode glyph without adjacent `sr-only` text. The `sr-only` text uses the German label (see i18n decision below). ## Styling & layout - Card pattern: `rounded-sm border border-line bg-surface shadow-sm p-6`. - Year/section headings: `text-xs font-bold uppercase tracking-widest text-ink-3`. - **`font-serif` (Tinos)** for names and event/letter titles; **`font-sans`** for date labels and metadata chrome. Body text ≥ 16px (`text-base`) for senior readers — prefer `text-lg` (18px) for description/snippet text. Never `text-sm` for body content. - **Mobile-first single column.** Responsive floor is **320px** (test there, not only 375px). The sender → receiver letter row must `flex-wrap` with `min-w-0` so long names wrap instead of pushing the card past the viewport — no horizontal overflow. - `LetterCard` link: visible `focus-visible:ring-2 focus-visible:ring-brand-navy`, `min-h-[44px] flex items-center` (≥44px touch target). - Use the shared `<BackButton>` from `$lib/shared/primitives/BackButton.svelte`. - Dark-mode tokens throughout. ### Mandatory states (reliability) - **Empty:** zero years + zero undated → an empty-state message (`timeline.empty_state` i18n key, German: "Noch keine Ereignisse"), never a blank page. Placed inside a meaningful landmark (inside `<main>`) with no nested conditions. - **Undated section** uses `{#if undated.length > 0}` — removes the section from the DOM entirely when empty (not `aria-hidden`). A `<section>` with `<h2>Ohne Datum</h2>` when present. - **Loading/error:** handled by the SSR load + SvelteKit error boundary via the mapped message above. - **No `{@html}`** anywhere in this component tree — render free text (`description`, `title`, names) with plain `{...}` interpolation; for line breaks use `whitespace-pre-line` CSS, not `{@html}`. ## i18n Add keys to `messages/{de,en,es}.json`. German is primary; labels for derived events are **German-only** across all locales for MVP (see Decisions). The full key list: | Key | de | en | es | |---|---|---|---| | `timeline.heading` | Zeitstrahl | Timeline | Línea de tiempo | | `timeline.empty_state` | Noch keine Ereignisse | No events yet | Aún no hay eventos | | `timeline.undated_section` | Ohne Datum | Without Date | Sin Fecha | | `timeline.layer.world` | Weltgeschehen | Weltgeschehen | Weltgeschehen | | `timeline.layer.family` | Familie | Familie | Familie | | `timeline.derived.birth` | Geburt | Geburt | Geburt | | `timeline.derived.death` | Tod | Tod | Tod | | `timeline.derived.marriage` | Heirat | Heirat | Heirat | The EN/ES columns intentionally carry the German value for the derived-event and layer labels — this is a documented MVP decision, not an oversight. ## Required doc updates (blockers at PR time) - `CLAUDE.md` route table — add `/zeitstrahl`. - `docs/architecture/c4/l3-frontend-*.puml` — add the `/zeitstrahl` route + `lib/timeline/` dir. - `docs/architecture/db/db-orm.puml` and `db-relationships.puml` — the new `timeline_events`, `timeline_event_persons`, `timeline_event_documents` tables must appear (these tables are introduced in issue 2/5; confirm they are present before this PR merges). - Frontend structure docs — note the new `frontend/src/lib/timeline/` domain dir. - `GLOSSARY.md` — add entries for: "Zeitstrahl", "TimelineEvent", "EventType (PERSONAL/HISTORICAL)", "derived event", "Lebensweg". - ADR-035 — authored as part of issue 2; confirm it exists before this PR merges. ## Acceptance criteria - **AC-RENDER:** Years render top-to-bottom (one `<section>` + `<h2>` per band); each band shows its events + letters; the undated section is present when there are undated items. - **AC-ORDER:** Given a band with a DAY-precision letter (`1923-04-12`) and a YEAR-precision letter (`1923`), the DAY item appears **above** the YEAR item. (Ordering comes from the issue-5 DTO; the component renders in DTO order — no client-side re-sorting.) - **AC-SAME-PRECISION-ORDER:** Given two entries with identical precision and year, they render in the order the DTO delivers them — the component is a pass-through, not a sorter. - **AC-RANGE:** Given a `RANGE` event `1914–1918`, it appears **once**, in the 1914 band, with a visible pill span marker — not repeated in 1915–1918. - **AC-RANGE-NULL:** Given `precision === 'RANGE'` with `eventDateEnd` null, `EventCard` renders without a span marker and does not crash. - **AC-UNDATED:** Given items with `UNKNOWN` precision, they appear only in the "Ohne Datum" section at the end, and that section is **absent from the DOM when empty** (`{#if}` block, not `aria-hidden`). - **AC-EMPTY:** Given no events and no letters, `/zeitstrahl` renders an empty-state message (`timeline.empty_state`), not a blank page. - **AC-PERSON-ID-PROP:** When `personId` is undefined, `TimelineView` renders the global timeline (all years and entries from the DTO) with no filtering — confirms the prop is a no-op when unset. - **AC-DERIVED-STYLE:** Each of {HISTORICAL, PERSONAL-curated, PERSONAL-derived} renders its accent **and** its redundant non-color cue per the matrix above. Derived-event glyphs (✳ † ⚭) are wrapped with `aria-hidden="true"` + adjacent `sr-only` label text. - **AC-LINK:** `LetterCard` renders an internal link whose href is exactly `/documents/{documentId}`. - **AC-TOUCH:** `LetterCard` link has `min-h-[44px]` applied (≥44px touch target). - **AC-RESPONSIVE:** Renders with no horizontal overflow at **320px** — verified via Playwright E2E (`frontend/e2e/zeitstrahl.spec.ts`, see Tests). - **AC-A11Y:** Each year band and the undated bucket is a `<section>` with a heading; the whole timeline forms a navigable heading list; year headings are sticky. Verified manually pre-merge; automated axe gate deferred to issue 11. ## Tests (TDD) ### Component tests — `frontend/src/lib/timeline/TimelineView.svelte.spec.ts` (`*.svelte.spec.ts`, vitest-browser-svelte, real DOM, assert via `getByRole`/`getByText`): - renders one section per year with year headings as headings; - renders an `EventCard` and a `LetterCard` within the correct band; - AC-ORDER: DAY-precision entry before YEAR-precision entry within a band (DTO order preserved); - AC-SAME-PRECISION-ORDER: two YEAR-precision entries in same band render in DTO order; - AC-RANGE: RANGE event appears once in its start band with a pill span marker; - AC-RANGE-NULL: RANGE event with null `eventDateEnd` renders without pill, no crash; - AC-UNDATED: undated entries in the "Ohne Datum" section; section absent from DOM when no undated items; - AC-EMPTY: zero years + zero undated → empty message (`timeline.empty_state` text visible); - AC-PERSON-ID-PROP: `personId` undefined renders global timeline; no prop mutation; - AC-DERIVED-STYLE: each layer shows its redundant cue (text label visible via `getByText`); glyph wrapped with `sr-only` sibling; - AC-LINK: LetterCard href is `/documents/{id}`; - AC-TOUCH: LetterCard link element has `min-h-[44px]` class. Use a `makeTimelineDTO(overrides)` / `makeEntry(overrides)` factory — don't hand-build the nested DTO in each test. Place the factory in **`frontend/src/lib/timeline/test-factories.ts`** so it is importable from both this spec and future issue-10 tests. Minimum factory shape: ```typescript makeTimelineDTO({ years: [...], undated: [...] }) makeEntry({ kind: 'LETTER', precision: 'DAY', documentId: '...', year: 1923 }) makeEntry({ kind: 'EVENT', type: 'HISTORICAL', precision: 'RANGE', eventDateEnd: 1918 }) ``` Run targeted single-file locally (`--project=client` for the `.svelte.spec.ts`); leave the full sweep to CI. ### Server load test — `frontend/src/routes/zeitstrahl/page.server.test.ts` (node, plain TS, mock `createApiClient` — **filename: `page.server.test.ts`**, not `+page.server.test.ts`, matching codebase convention in `stammbaum/page.server.test.ts`): - `load` returns `{ timeline: data }` on ok; - throws `error(404, mappedMessage)` on 404 non-ok; - throws `error(500, mappedMessage)` on 500 non-ok; - throws/redirects on 401 (session expiry → /login pattern, matching `stammbaum/page.server.test.ts` lines 63–69); - throws `error(403, mappedMessage)` on 403 — ensures `READ_ALL`-less sessions produce a user-friendly message, not raw JSON. `dateLabel.ts` precision rendering is tested in **issue 6** — here, only assert the card renders the consumed label (e.g. render a `LetterCard` with `precision: 'MONTH'` and assert the label text contains the MONTH output from `dateLabel.ts`). ### E2E test — `frontend/e2e/zeitstrahl.spec.ts` (Playwright) - AC-RESPONSIVE: `page.setViewportSize({ width: 320, height: 812 })`, navigate to `/zeitstrahl`, assert `await page.evaluate(() => document.body.scrollWidth) === 320` (no horizontal overflow). ## Decisions resolved (Round 0 — from original spec) - **Letter-cluster-under-event in the global view → DEFERRED.** This issue places every letter once, in its own band; `EventCard` renders no child letters. Clustering is curator-driven (linking in issue 9) and most meaningful in the per-person view (issue 10) — shipping the simplest correct rendering first keeps `EventCard`/`LetterCard` as flat leaf cards and avoids speculative composition. - **Spine affordance → sticky per-year heading now; jump-to-year rail deferred to issue 11.** `position: sticky` on the `<h2>` year heading is near-free and solves long-scroll orientation on phones; combined with semantic `<section>`/`<h2>` (screen-reader heading navigation) it covers the MVP. ## Decisions resolved (Round 1 — from review) - **`DatePrecision` package ownership → keep in `document/`, import across domain boundary (Option A).** The enum is shared vocabulary — importing it from `document/` in `timeline/` is a bounded, read-only, non-cyclic dependency. Promoting to `shared/` would touch `Document`, `TranscriptionBlock`, and all importers for a marginal structural benefit. KISS wins. Documented in ADR-035 (issue 2) so a future architect understands the deliberate trade-off. - **ADR-035 → written as part of issue 2, not this issue; confirmed present before this PR merges.** The architecture decision belongs with the backend domain introduction (issue 2), not the frontend route. This issue's PR checklist requires ADR-035 to exist before merge. - **Derived-event glyph label language → German-only for MVP (Option A).** The archive's primary audience and family context is German; glyph labels (Geburt/Tod/Heirat) are proper German nouns universally understood in this context. EN/ES locales carry the German value intentionally — documented in the i18n table above as a deliberate MVP choice, not an oversight. Adding 6 translation strings buys negligible value at current scale. - **"Ohne Datum" section visibility → `{#if undated.length > 0}` (removes from DOM).** Not `aria-hidden`. Removing from the DOM is cleaner for screen readers — hidden content behind `aria-hidden` still occupies the accessibility tree and can confuse navigation. `{#if}` is the idiomatic Svelte pattern. - **i18n key for empty state → `timeline.empty_state`.** Matches the scoped key naming convention used elsewhere (`timeline.*` namespace). German value: "Noch keine Ereignisse." - **`RANGE` event with null `eventDateEnd` → render without span marker, no crash.** Graceful degradation: the component silently omits the pill. Backend validation is not guaranteed to prevent this edge case; crashing here would produce a blank timeline page. - **Page server test filename → `page.server.test.ts`** (no `+` prefix). Matches the established codebase convention (`stammbaum/page.server.test.ts`). - **AC-RESPONSIVE → Playwright E2E spec, not vitest-browser.** Layout overflow at 320px is not assertable in vitest-browser-svelte (no real viewport control). The component test asserts `flex-wrap`/`min-w-0` class presence as a surrogate; the real overflow assertion lives in `frontend/e2e/zeitstrahl.spec.ts` with `page.setViewportSize`.
marcel added this to the Zeitstrahl — Family Timeline milestone 2026-06-07 19:29:22 +02:00
marcel added the P2-mediumfeatureui labels 2026-06-07 19:30:03 +02:00
Author
Owner

🏗️ Markus Keller — Senior Application Architect

Observations

The spec is solid — these are refinements, not reversals.

Round 1 resolved the two biggest open questions (DatePrecision package ownership and ADR-035 scope). What remains are structural gaps that could cause friction during implementation.

ADR-035 numbering is confirmed. The latest ADR is 034 (Ollama deployment). ADR-035 is correct for the timeline domain. The blocker "ADR-035 must exist before this PR merges" is appropriate — what's missing from the issue is a pointer to which issue writes it (mentioned as "issue 2" but the issue number is not linked in this ticket). A developer picking up this ticket cannot easily navigate to it.

TimelineView vs YearBand responsibility split. The spec says "Do not pass personId down to YearBand or EventCard." This is correct. But it implies TimelineView must split the incoming DTO into yearBands and undated — those are $derived values in the component, not structural components of the DTO. The issue should confirm: TimelineDTO.years is already year-bucketed (one TimelineYearDTO per year), so TimelineView just iterates it — no re-bucketing needed. This is stated ("the component renders entries in DTO order") but the exact DTO shape could be clearer for implementors. The spec says TimelineDTO = { years: TimelineYearDTO[], undated: TimelineEntryDTO[] } which is clear — good.

Cross-domain import DatePrecision from document/. The decision to keep it in document/ (Option A) is documented in the Decisions section. However, the issue says this is documented in ADR-035 (issue 2). For the implementor of this issue (issue 7), the import path $lib/document/... will feel like a boundary violation unless they've read ADR-035 first. Recommend: add a one-line code comment in dateLabel.ts (or a note in this issue) referencing the decision — // DatePrecision imported from document/: see ADR-035.

C4 diagram updates are a PR blocker. The issue lists docs/architecture/c4/l3-frontend-*.puml but does not specify which file. Looking at the existing frontend C4 files: l3-frontend-3c-people-stories.puml is the most likely home for the /zeitstrahl route given it already tracks stories and persons. The implementor should not have to guess — naming the exact file in the doc-update checklist reduces friction.

lib/document/timeline.ts naming collision risk. The existing frontend/src/lib/document/timeline.ts is a document density / timeline widget helper (unrelated to the Zeitstrahl feature). The new frontend/src/lib/timeline/dateLabel.ts belongs to the new domain. These do not conflict, but a developer grepping for "timeline" will find two files with unrelated purposes. This is cosmetic but worth noting — both names are correct within their respective domains.

Recommendations

  • In the PR checklist, name l3-frontend-3c-people-stories.puml explicitly as the diagram to update (or create a new l3-frontend-3e-timeline.puml if the timeline domain is large enough to warrant its own diagram).
  • Add a cross-reference from this issue to the issue 2 Gitea issue number (the one that contains ADR-035). A developer cannot find "issue 2 from milestone Zeitstrahl" without navigating the milestone list.
  • The "Required doc updates" list is complete and well-structured — no gaps found. The inclusion of GLOSSARY.md entries (Zeitstrahl, TimelineEvent, EventType, derived event, Lebensweg) is the right level of documentation discipline.

Open Decisions (none)

All architectural decisions from this angle are resolved.

## 🏗️ Markus Keller — Senior Application Architect ### Observations **The spec is solid — these are refinements, not reversals.** Round 1 resolved the two biggest open questions (DatePrecision package ownership and ADR-035 scope). What remains are structural gaps that could cause friction during implementation. **ADR-035 numbering is confirmed.** The latest ADR is 034 (Ollama deployment). ADR-035 is correct for the timeline domain. The blocker "ADR-035 must exist before this PR merges" is appropriate — what's missing from the issue is a pointer to *which issue* writes it (mentioned as "issue 2" but the issue number is not linked in this ticket). A developer picking up this ticket cannot easily navigate to it. **`TimelineView` vs `YearBand` responsibility split.** The spec says "Do not pass `personId` down to `YearBand` or `EventCard`." This is correct. But it implies `TimelineView` must split the incoming DTO into `yearBands` and `undated` — those are `$derived` values in the component, not structural components of the DTO. The issue should confirm: `TimelineDTO.years` is already year-bucketed (one `TimelineYearDTO` per year), so `TimelineView` just iterates it — no re-bucketing needed. This is stated ("the component renders entries in DTO order") but the exact DTO shape could be clearer for implementors. The spec says `TimelineDTO = { years: TimelineYearDTO[], undated: TimelineEntryDTO[] }` which is clear — good. **Cross-domain import `DatePrecision` from `document/`.** The decision to keep it in `document/` (Option A) is documented in the Decisions section. However, the issue says this is documented in ADR-035 (issue 2). For the implementor of *this* issue (issue 7), the import path `$lib/document/...` will feel like a boundary violation unless they've read ADR-035 first. Recommend: add a one-line code comment in `dateLabel.ts` (or a note in this issue) referencing the decision — `// DatePrecision imported from document/: see ADR-035`. **C4 diagram updates are a PR blocker.** The issue lists `docs/architecture/c4/l3-frontend-*.puml` but does not specify *which* file. Looking at the existing frontend C4 files: `l3-frontend-3c-people-stories.puml` is the most likely home for the `/zeitstrahl` route given it already tracks stories and persons. The implementor should not have to guess — naming the exact file in the doc-update checklist reduces friction. **`lib/document/timeline.ts` naming collision risk.** The existing `frontend/src/lib/document/timeline.ts` is a document density / timeline widget helper (unrelated to the Zeitstrahl feature). The new `frontend/src/lib/timeline/dateLabel.ts` belongs to the new domain. These do not conflict, but a developer grepping for "timeline" will find two files with unrelated purposes. This is cosmetic but worth noting — both names are correct within their respective domains. ### Recommendations - In the PR checklist, name `l3-frontend-3c-people-stories.puml` explicitly as the diagram to update (or create a new `l3-frontend-3e-timeline.puml` if the timeline domain is large enough to warrant its own diagram). - Add a cross-reference from this issue to the issue 2 Gitea issue number (the one that contains ADR-035). A developer cannot find "issue 2 from milestone Zeitstrahl" without navigating the milestone list. - The "Required doc updates" list is complete and well-structured — no gaps found. The inclusion of `GLOSSARY.md` entries (Zeitstrahl, TimelineEvent, EventType, derived event, Lebensweg) is the right level of documentation discipline. ### Open Decisions _(none)_ All architectural decisions from this angle are resolved.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

The spec has matured well from Round 0. Decisions are resolved, the load pattern is explicit, and the test factory shape is defined. These are implementation-level gaps that will cause friction or bugs if not addressed now.

makeEntry factory shape is underspecified for RANGE events. The factory signature shows makeEntry({ kind: 'EVENT', type: 'HISTORICAL', precision: 'RANGE', eventDateEnd: 1918 }) — but eventDateEnd in the DTO is LocalDate (serialized as a string like "1918-01-01"), not a raw number. The factory must produce what the backend actually emits, or the AC-RANGE test will test the wrong shape. Clarify: eventDateEnd is either string | null (ISO date) or number | null (year integer) — the DTO shape from issue 5 will define this. The factory should mirror it exactly.

The {#each} keying spec is correct but the composite key expression needs attention. The issue specifies (entry.kind + ':' + (entry.eventId ?? entry.documentId)). In Svelte 5, {#each entries as entry (entry.kind + ':' + (entry.eventId ?? entry.documentId))} works, but the key is a string concatenation that silently collapses if both eventId and documentId are null (producing kind + ':' + 'undefined'). The spec notes "avoid UUID-space collisions" but doesn't address the null case. Recommend: the factory should always produce either an eventId or a documentId (never both null), and this constraint should be enforced in the DTO — a test should assert that no entry has both null.

EventCard 60-line limit with eventCardConfig.ts. The spec says: "if it exceeds 60 lines with the glyph/icon/label matrix, split into a getAccentConfig(entry) helper imported from eventCardConfig.ts." This is a conditional — which means the implementor may skip the split. Given that the matrix covers 3 types × (color + icon + label + derived variants), 60 lines will almost certainly be exceeded. Make the split unconditional: always extract eventCardConfig.ts. The component will be cleaner and the config will be importable from tests.

$derived vs $derived.by() choice. The spec says "Use $derived (never $effect) for any view-derived state." For yearBands and undated these will require multi-step computation from the DTO (timeline.years → rendered array, timeline.undated → rendered array). Use $derived.by() for both — $derived is for single expressions, $derived.by() for multi-step.

font-serif / font-sans split at element level. The spec is explicit: "Apply this split at the element level within LetterCard and EventCard, not just at the card level." This is easy to miss during implementation. The test suite has no assertion for font classes — which is fine (testing CSS classes via getByRole is brittle), but a code review checklist item noting "verify font-serif on names, font-sans on dates" would prevent this slipping through.

Test file naming conventions confirmed. TimelineView.svelte.spec.ts (component, *.svelte.spec.ts) and page.server.test.ts (load, *.test.ts) — both match the codebase convention verified against stammbaum/. Good. The test-factories.ts in frontend/src/lib/timeline/ is the right location for cross-issue reuse.

load pattern matches stammbaum/+page.server.ts exactly — the 401 → redirect to /login pattern is present in stammbaum and the spec correctly calls it out as the pattern to match. The server load test spec (page.server.test.ts) matches the stammbaum test structure.

Recommendations

  • Clarify eventDateEnd type in the factory spec: string (ISO date) or number (year), matching what TimelineEntryDTO actually emits from issue 5.
  • Make eventCardConfig.ts unconditional — always extract it; do not leave it as a conditional "if 60 lines."
  • Add a test assertion in the component spec for the null-key edge case: an entry with both eventId: null and documentId: null should either be disallowed (preferred) or produce a unique key without crashing.

Open Decisions (none)

The implementation path is clear.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations The spec has matured well from Round 0. Decisions are resolved, the load pattern is explicit, and the test factory shape is defined. These are implementation-level gaps that will cause friction or bugs if not addressed now. **`makeEntry` factory shape is underspecified for `RANGE` events.** The factory signature shows `makeEntry({ kind: 'EVENT', type: 'HISTORICAL', precision: 'RANGE', eventDateEnd: 1918 })` — but `eventDateEnd` in the DTO is `LocalDate` (serialized as a string like `"1918-01-01"`), not a raw `number`. The factory must produce what the backend actually emits, or the AC-RANGE test will test the wrong shape. Clarify: `eventDateEnd` is either `string | null` (ISO date) or `number | null` (year integer) — the DTO shape from issue 5 will define this. The factory should mirror it exactly. **The `{#each}` keying spec is correct but the composite key expression needs attention.** The issue specifies `(entry.kind + ':' + (entry.eventId ?? entry.documentId))`. In Svelte 5, `{#each entries as entry (entry.kind + ':' + (entry.eventId ?? entry.documentId))}` works, but the key is a string concatenation that silently collapses if both `eventId` and `documentId` are null (producing `kind + ':' + 'undefined'`). The spec notes "avoid UUID-space collisions" but doesn't address the null case. Recommend: the factory should always produce either an `eventId` or a `documentId` (never both null), and this constraint should be enforced in the DTO — a test should assert that no entry has both null. **`EventCard` 60-line limit with `eventCardConfig.ts`.** The spec says: "if it exceeds 60 lines with the glyph/icon/label matrix, split into a `getAccentConfig(entry)` helper imported from `eventCardConfig.ts`." This is a conditional — which means the implementor may skip the split. Given that the matrix covers 3 types × (color + icon + label + derived variants), 60 lines will almost certainly be exceeded. Make the split unconditional: always extract `eventCardConfig.ts`. The component will be cleaner and the config will be importable from tests. **`$derived` vs `$derived.by()` choice.** The spec says "Use `$derived` (never `$effect`) for any view-derived state." For `yearBands` and `undated` these will require multi-step computation from the DTO (`timeline.years` → rendered array, `timeline.undated` → rendered array). Use `$derived.by()` for both — `$derived` is for single expressions, `$derived.by()` for multi-step. **`font-serif` / `font-sans` split at element level.** The spec is explicit: "Apply this split at the element level within `LetterCard` and `EventCard`, not just at the card level." This is easy to miss during implementation. The test suite has no assertion for font classes — which is fine (testing CSS classes via `getByRole` is brittle), but a code review checklist item noting "verify font-serif on names, font-sans on dates" would prevent this slipping through. **Test file naming conventions confirmed.** `TimelineView.svelte.spec.ts` (component, `*.svelte.spec.ts`) and `page.server.test.ts` (load, `*.test.ts`) — both match the codebase convention verified against `stammbaum/`. Good. The `test-factories.ts` in `frontend/src/lib/timeline/` is the right location for cross-issue reuse. **`load` pattern matches `stammbaum/+page.server.ts` exactly** — the 401 → redirect to `/login` pattern is present in stammbaum and the spec correctly calls it out as the pattern to match. The server load test spec (`page.server.test.ts`) matches the stammbaum test structure. ### Recommendations - Clarify `eventDateEnd` type in the factory spec: string (ISO date) or number (year), matching what `TimelineEntryDTO` actually emits from issue 5. - Make `eventCardConfig.ts` unconditional — always extract it; do not leave it as a conditional "if 60 lines." - Add a test assertion in the component spec for the null-key edge case: an entry with both `eventId: null` and `documentId: null` should either be disallowed (preferred) or produce a unique key without crashing. ### Open Decisions _(none)_ The implementation path is clear.
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Observations

This issue is purely a read-only frontend route with no write endpoints, no file uploads, and no user-generated content rendered as HTML. The security profile is correspondingly narrow. Round 1 resolved no security decisions — this area was clean from the start. Confirming what I verified and surfacing two items worth noting.

No {@html} — spec is explicit. The spec bans {@html} anywhere in the component tree and requires plain {...} interpolation for description, title, and names. This is the right call: any {@html} here would be an XSS vector given that description and title are user-curated free text. The spec's alternative (CSS whitespace-pre-line for line breaks) is correct. Verify the ban is enforced at code review time — it is not currently in the test suite (ESLint could catch this with no-svelte-html rules, but that's out of scope for this issue).

URL parameter handling. The /zeitstrahl route has no query parameters in this issue (filters are issue 8). The +page.server.ts load function calls api.GET('/api/timeline') with no user-supplied input — no injection surface. Clean.

LetterCard link is constructed from documentId (UUID), not from free text. The spec is explicit: "never from any free-text field." UUIDs are schema-validated by the backend before reaching this DTO. An XSS attack via href injection is not possible here. The prohibition on target="_blank" (no orphaned opener) is correctly stated and prevents window.opener attacks.

parsePanZoomParams equivalent. Stammbaum (#692) had a validated URL parameter attack surface (Nora-referenced in the stammbaum load file). This issue has no query params — no equivalent risk exists here. The issue 8 filter params will introduce this surface and should be reviewed at that point.

Session auth — no new permissions needed. The spec confirms GET /api/timeline requires READ_ALL (standard authenticated session). No new ErrorCode or Permission values are introduced. The security boundary is correctly described.

Dark-mode contrast for HISTORICAL gray accent. The spec explicitly flags this as the likeliest dark-mode AA failure: "muted 'historical' gray is the likeliest dark-mode AA failure — verify computed color on the EventCard HISTORICAL accent against its background." This is a UI concern but has a security dimension: color-contrast failure is an accessibility barrier (WCAG 1.4.3), and accessibility barriers can constitute legal exposure in some jurisdictions. Flag it as a PR-time verification requirement, not just a nice-to-have.

Recommendations

  • No security changes needed for this issue.
  • At PR time: verify the {@html} ban by grep — grep -r '@html' frontend/src/lib/timeline/ should return zero results.
  • Defer URL parameter security review to issue 8 (filter params introduction).

Open Decisions (none)

Security surface is well-constrained for a read-only SSR route.

## 🔒 Nora "NullX" Steiner — Application Security Engineer ### Observations This issue is purely a read-only frontend route with no write endpoints, no file uploads, and no user-generated content rendered as HTML. The security profile is correspondingly narrow. Round 1 resolved no security decisions — this area was clean from the start. Confirming what I verified and surfacing two items worth noting. **No `{@html}` — spec is explicit.** The spec bans `{@html}` anywhere in the component tree and requires plain `{...}` interpolation for `description`, `title`, and names. This is the right call: any `{@html}` here would be an XSS vector given that `description` and `title` are user-curated free text. The spec's alternative (CSS `whitespace-pre-line` for line breaks) is correct. Verify the ban is enforced at code review time — it is not currently in the test suite (ESLint could catch this with `no-svelte-html` rules, but that's out of scope for this issue). **URL parameter handling.** The `/zeitstrahl` route has no query parameters in this issue (filters are issue 8). The `+page.server.ts` load function calls `api.GET('/api/timeline')` with no user-supplied input — no injection surface. Clean. **`LetterCard` link is constructed from `documentId` (UUID), not from free text.** The spec is explicit: "never from any free-text field." UUIDs are schema-validated by the backend before reaching this DTO. An XSS attack via `href` injection is not possible here. The prohibition on `target="_blank"` (no orphaned opener) is correctly stated and prevents window.opener attacks. **`parsePanZoomParams` equivalent.** Stammbaum (#692) had a validated URL parameter attack surface (Nora-referenced in the stammbaum load file). This issue has no query params — no equivalent risk exists here. The issue 8 filter params will introduce this surface and should be reviewed at that point. **Session auth — no new permissions needed.** The spec confirms `GET /api/timeline` requires `READ_ALL` (standard authenticated session). No new `ErrorCode` or `Permission` values are introduced. The security boundary is correctly described. **Dark-mode contrast for HISTORICAL gray accent.** The spec explicitly flags this as the likeliest dark-mode AA failure: "muted 'historical' gray is the likeliest dark-mode AA failure — verify computed `color` on the `EventCard` HISTORICAL accent against its background." This is a UI concern but has a security dimension: color-contrast failure is an accessibility barrier (WCAG 1.4.3), and accessibility barriers can constitute legal exposure in some jurisdictions. Flag it as a PR-time verification requirement, not just a nice-to-have. ### Recommendations - No security changes needed for this issue. - At PR time: verify the `{@html}` ban by grep — `grep -r '@html' frontend/src/lib/timeline/` should return zero results. - Defer URL parameter security review to issue 8 (filter params introduction). ### Open Decisions _(none)_ Security surface is well-constrained for a read-only SSR route.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Observations

The test strategy is well thought out for Round 2 — the factory shape, test file naming, and pyramid split are all correct. These are gaps and ambiguities that will cause test failures or missing coverage if not addressed.

AC-TOUCH: min-h-[44px] class presence is a structural assertion, not a behavior assertion. The issue says to assert the class is present on the <a> element. In vitest-browser-svelte this means expect(element).toHaveClass('min-h-[44px]'). This is fragile if the implementation achieves 44px via a different mechanism (e.g., py-3 on a flex container). A more robust assertion: getComputedStyle(element).minHeight === '44px' or simply ensuring the element's bounding box height is ≥ 44px via getBoundingClientRect(). The class-presence check is a reasonable proxy but should be documented as such.

AC-RANGE test setup requires a RANGE entry with eventDateStart = 1914 and eventDateEnd = 1918. The component test must verify the entry appears in the 1914 band only — not in 1915–1918. But the DTO yields a flat list of entries per year; the backend (issue 5) places the RANGE item in the 1914 bucket only. So the test needs to construct a DTO with a year band for 1914 (containing the RANGE entry) and year bands for 1915, 1916, 1917, 1918 (each containing zero RANGE entries). The factory must support building multi-year DTOs for this. The current factory spec (makeTimelineDTO({ years: [...], undated: [...] })) supports this but the test specification doesn't describe this multi-band setup explicitly. Recommend: add a note in the test spec for AC-RANGE confirming that 1915–1918 bands are included in the test DTO with no RANGE entry in them.

AC-RESPONSIVE is the only E2E spec. The zeitstrahl.spec.ts Playwright file has a single test. This is intentional per the issue ("AC-RESPONSIVE ... verified via Playwright E2E"). However, zeitstrahl.spec.ts will be the entry point for future axe tests (issue 11) and filter tests (issue 8). Recommend setting up the file with a describe('zeitstrahl') block and a beforeEach navigation so future tests can be added without restructuring.

Server load test — 401 redirect pattern must match stammbaum/page.server.test.ts lines 63–69. I verified the stammbaum test: await expect(load(...)).rejects.toMatchObject({ status: 302, location: '/login' }). The spec calls this out correctly. The 403 case is new in this spec (not present in stammbaum) — ensure the mock returns { response: { ok: false, status: 403 }, error: { code: 'X' } } and that getErrorMessage maps code 'X' to a string (or use a known ErrorCode value in the mock).

dateLabel.ts coverage. The spec states "precision rendering is tested in issue 6 — here, only assert the card renders the consumed label." This is the right call — don't duplicate test coverage. But the component test for MONTH precision must import dateLabel.ts to compute the expected string, not hardcode it, so the test stays correct if the formatting changes.

Missing: test for the empty-state heading's landmark. AC-EMPTY says "empty-state message visible" but doesn't assert the message is inside <main>. The spec says "Placed inside a meaningful landmark (inside <main>) with no nested conditions." A test using getByRole('main') should contain the empty-state text — add this assertion to the AC-EMPTY test.

Recommendations

  • In the AC-RANGE component test, include non-1914 year bands in the test DTO to verify the entry does not appear in them.
  • Change AC-TOUCH assertion to getBoundingClientRect().height >= 44 or computed style check — more robust than class-presence.
  • Add a beforeEach page navigation stub to zeitstrahl.spec.ts so future tests (issue 8, 11) slot in cleanly.
  • Add an assertion that the empty-state message is contained within the <main> landmark.

Open Decisions (none)

All test strategy questions are answered by the spec.

## 🧪 Sara Holt — QA Engineer & Test Strategist ### Observations The test strategy is well thought out for Round 2 — the factory shape, test file naming, and pyramid split are all correct. These are gaps and ambiguities that will cause test failures or missing coverage if not addressed. **AC-TOUCH: `min-h-[44px]` class presence is a structural assertion, not a behavior assertion.** The issue says to assert the class is present on the `<a>` element. In vitest-browser-svelte this means `expect(element).toHaveClass('min-h-[44px]')`. This is fragile if the implementation achieves 44px via a different mechanism (e.g., `py-3` on a flex container). A more robust assertion: `getComputedStyle(element).minHeight === '44px'` or simply ensuring the element's bounding box height is ≥ 44px via `getBoundingClientRect()`. The class-presence check is a reasonable proxy but should be documented as such. **AC-RANGE test setup requires a `RANGE` entry with `eventDateStart = 1914` and `eventDateEnd = 1918`.** The component test must verify the entry appears in the 1914 band *only* — not in 1915–1918. But the DTO yields a flat list of entries per year; the backend (issue 5) places the RANGE item in the 1914 bucket only. So the test needs to construct a DTO with a year band for 1914 (containing the RANGE entry) and year bands for 1915, 1916, 1917, 1918 (each containing zero RANGE entries). The factory must support building multi-year DTOs for this. The current factory spec (`makeTimelineDTO({ years: [...], undated: [...] })`) supports this but the test specification doesn't describe this multi-band setup explicitly. Recommend: add a note in the test spec for AC-RANGE confirming that 1915–1918 bands are included in the test DTO with no RANGE entry in them. **AC-RESPONSIVE is the only E2E spec.** The `zeitstrahl.spec.ts` Playwright file has a single test. This is intentional per the issue ("AC-RESPONSIVE ... verified via Playwright E2E"). However, `zeitstrahl.spec.ts` will be the entry point for future axe tests (issue 11) and filter tests (issue 8). Recommend setting up the file with a `describe('zeitstrahl')` block and a `beforeEach` navigation so future tests can be added without restructuring. **Server load test — 401 redirect pattern must match `stammbaum/page.server.test.ts` lines 63–69.** I verified the stammbaum test: `await expect(load(...)).rejects.toMatchObject({ status: 302, location: '/login' })`. The spec calls this out correctly. The 403 case is new in this spec (not present in stammbaum) — ensure the mock returns `{ response: { ok: false, status: 403 }, error: { code: 'X' } }` and that `getErrorMessage` maps code 'X' to a string (or use a known `ErrorCode` value in the mock). **`dateLabel.ts` coverage.** The spec states "precision rendering is tested in issue 6 — here, only assert the card renders the consumed label." This is the right call — don't duplicate test coverage. But the component test for `MONTH` precision must import `dateLabel.ts` to compute the expected string, not hardcode it, so the test stays correct if the formatting changes. **Missing: test for the empty-state heading's landmark.** AC-EMPTY says "empty-state message visible" but doesn't assert the message is inside `<main>`. The spec says "Placed inside a meaningful landmark (inside `<main>`) with no nested conditions." A test using `getByRole('main')` should contain the empty-state text — add this assertion to the AC-EMPTY test. ### Recommendations - In the AC-RANGE component test, include non-1914 year bands in the test DTO to verify the entry does not appear in them. - Change AC-TOUCH assertion to `getBoundingClientRect().height >= 44` or computed style check — more robust than class-presence. - Add a `beforeEach` page navigation stub to `zeitstrahl.spec.ts` so future tests (issue 8, 11) slot in cleanly. - Add an assertion that the empty-state message is contained within the `<main>` landmark. ### Open Decisions _(none)_ All test strategy questions are answered by the spec.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

Round 1 absorbed many of my concerns — the font split, touch targets, sticky headings, semantic section/h2 structure, and the glyph sr-only pattern are all now explicit in the spec. These are the remaining gaps from a UX and accessibility standpoint.

Sticky year heading: position: sticky value is unspecified. The spec says "sticky (position: sticky / sticky top-…)" but does not specify the top value. On mobile with a sticky nav bar, the wrong value will cause year headings to overlap with the navigation. The top value must account for the global nav height. Looking at the project: the global layout has a sticky header — the top offset for year headings must be exactly the header height (or zero if the header is not sticky on this page). This needs to be specified or verified at implementation time; if it's wrong, the sticky affordance actively hurts orientation rather than helping.

Empty state message font. The spec says text-base or text-lg for body content, never text-sm. The empty state message ("Noch keine Ereignisse") is body content and must follow this rule — but the spec's empty-state section doesn't mention the font class for this message. Recommend: class="text-lg font-serif text-ink-2 text-center py-12" or equivalent — consistent with how other empty states render in the archive.

"Ohne Datum" section heading. The <h2>Ohne Datum</h2> heading will appear below all the year headings in the heading outline. Screen reader users navigating by headings will encounter "1899 … 1900 … 1901 … Ohne Datum." This is semantically correct — the section exists, has a heading, and its position in the outline is natural. No change needed; confirming this is intentional.

RANGE pill accessibility. The spec mandates a pill marker for RANGE events with aria-label="Zeitraum: 1914 bis 1918". The aria-label replaces the visual 1914–1918 text for screen readers. Verify the pill element also has role="img" or is a <span> (not interactive). A <span aria-label="..."> without role will not be announced as a landmark; it will be read as part of the text flow, which is acceptable. But if the pill is a <span> with content 1914–1918, the aria-label overrides that — confirm the pill shows the text visually and the aria-label provides the expanded description, not a replacement.

Dark mode: HISTORICAL gray accent contrast. The spec flags this explicitly. I want to reinforce it with a concrete test. The HISTORICAL accent color should be verified at the point of implementation — compute its contrast against bg-surface in dark mode using the Tailwind CSS 4 token values from layout.css. If the ratio falls below 4.5:1 for normal-size text, the color must be adjusted before merge. The spec requires this check but does not specify a fallback value if the check fails. Recommend: default to text-ink-2 for HISTORICAL label text in dark mode if the dedicated token fails contrast, rather than discovering this in production.

flex-wrap + min-w-0 on the sender→receiver row. The spec mandates this for 320px rendering. This is the single most common mobile overflow cause for letter cards with long German names (e.g., "Wilhelmine Raddatz-Brauer → Friedrich-Wilhelm Raddatz"). Verify at exactly 320px with a name that is 25+ characters on each side. The Playwright test asserts no horizontal overflow at the viewport level — also recommend a manual visual check with a long-name fixture before PR approval.

Body text ≥ 16px (text-base), prefer text-lg (18px) for description/snippet. The spec states this. For LetterCard snippet text, confirm text-base (16px) minimum is applied — this is the reading size for the 60+ transcriber audience. The card title can use text-base font-serif; the snippet/description should be text-lg font-serif.

Recommendations

  • Specify the exact top value for sticky year headings in relation to the global nav height (or note "verify against actual header height").
  • Add font class guidance for the empty-state message: text-lg font-serif text-ink-2.
  • Confirm the RANGE pill aria-label strategy: label overrides content (acceptable) vs. both visual text and expanded aria-label (ideal).
  • At PR time: verify HISTORICAL gray token contrast in dark mode. If it fails 4.5:1, use text-ink-2 as fallback.

Open Decisions (none)

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations Round 1 absorbed many of my concerns — the font split, touch targets, sticky headings, semantic section/h2 structure, and the glyph sr-only pattern are all now explicit in the spec. These are the remaining gaps from a UX and accessibility standpoint. **Sticky year heading: `position: sticky` value is unspecified.** The spec says "sticky (`position: sticky` / `sticky top-…`)" but does not specify the `top` value. On mobile with a sticky nav bar, the wrong value will cause year headings to overlap with the navigation. The `top` value must account for the global nav height. Looking at the project: the global layout has a sticky header — the `top` offset for year headings must be exactly the header height (or zero if the header is not sticky on this page). This needs to be specified or verified at implementation time; if it's wrong, the sticky affordance actively hurts orientation rather than helping. **Empty state message font.** The spec says `text-base` or `text-lg` for body content, never `text-sm`. The empty state message ("Noch keine Ereignisse") is body content and must follow this rule — but the spec's empty-state section doesn't mention the font class for this message. Recommend: `class="text-lg font-serif text-ink-2 text-center py-12"` or equivalent — consistent with how other empty states render in the archive. **"Ohne Datum" section heading.** The `<h2>Ohne Datum</h2>` heading will appear below all the year headings in the heading outline. Screen reader users navigating by headings will encounter "1899 … 1900 … 1901 … Ohne Datum." This is semantically correct — the section exists, has a heading, and its position in the outline is natural. No change needed; confirming this is intentional. **RANGE pill accessibility.** The spec mandates a pill marker for RANGE events with `aria-label="Zeitraum: 1914 bis 1918"`. The `aria-label` replaces the visual `1914–1918` text for screen readers. Verify the pill element also has `role="img"` or is a `<span>` (not interactive). A `<span aria-label="...">` without `role` will not be announced as a landmark; it will be read as part of the text flow, which is acceptable. But if the pill is a `<span>` with content `1914–1918`, the `aria-label` overrides that — confirm the pill shows the text visually *and* the aria-label provides the expanded description, not a replacement. **Dark mode: HISTORICAL gray accent contrast.** The spec flags this explicitly. I want to reinforce it with a concrete test. The HISTORICAL accent color should be verified at the point of implementation — compute its contrast against `bg-surface` in dark mode using the Tailwind CSS 4 token values from `layout.css`. If the ratio falls below 4.5:1 for normal-size text, the color must be adjusted before merge. The spec requires this check but does not specify a fallback value if the check fails. Recommend: default to `text-ink-2` for HISTORICAL label text in dark mode if the dedicated token fails contrast, rather than discovering this in production. **`flex-wrap` + `min-w-0` on the sender→receiver row.** The spec mandates this for 320px rendering. This is the single most common mobile overflow cause for letter cards with long German names (e.g., "Wilhelmine Raddatz-Brauer → Friedrich-Wilhelm Raddatz"). Verify at exactly 320px with a name that is 25+ characters on each side. The Playwright test asserts no horizontal overflow at the viewport level — also recommend a manual visual check with a long-name fixture before PR approval. **Body text ≥ 16px (`text-base`), prefer `text-lg` (18px) for description/snippet.** The spec states this. For `LetterCard` snippet text, confirm `text-base` (16px) minimum is applied — this is the reading size for the 60+ transcriber audience. The card title can use `text-base font-serif`; the snippet/description should be `text-lg font-serif`. ### Recommendations - Specify the exact `top` value for sticky year headings in relation to the global nav height (or note "verify against actual header height"). - Add font class guidance for the empty-state message: `text-lg font-serif text-ink-2`. - Confirm the RANGE pill `aria-label` strategy: label overrides content (acceptable) vs. both visual text and expanded aria-label (ideal). - At PR time: verify HISTORICAL gray token contrast in dark mode. If it fails 4.5:1, use `text-ink-2` as fallback. ### Open Decisions _(none)_
Author
Owner

📋 Elicit — Requirements Engineer

Observations

Round 1 folded in substantial decisions and the spec is now dense and precise. These are residual ambiguities and a missing NFR that could produce incorrect behavior or implementation confusion.

AC-ORDER and AC-SAME-PRECISION-ORDER together establish a contract but leave the "why" implicit. Both criteria assert "renders in DTO order." This is correct per the design. But the issue text says "the component is a pass-through, proven by the AC-ORDER test" — yet AC-ORDER verifies that DAY comes before YEAR, which could be interpreted as a client-side sort. A developer unfamiliar with the design principle might implement client-side sorting to make the test pass, which would violate the constraint. Recommend adding a comment to the test: // DTO delivers entries pre-sorted by issue-5 backend; this test verifies the component preserves, not produces, that order. This is a documentation gap, not a code gap, but it protects against future regressions when a new developer "fixes" sorting.

AC-PERSON-ID-PROP is underspecified. The criterion says "When personId is undefined, TimelineView renders the global timeline (all years and entries from the DTO) with no filtering." But it doesn't specify what the test DTO contains — if it's an empty DTO, the test passes vacuously. The test should use a DTO with at least one year band and one undated entry to prove the data flows through.

No NFR for initial load time. The page loads a potentially large DTO (all timeline events + derived person events + all letters). For an archive with 1,000+ documents spanning 50 years, the DTO could be substantial. No latency NFR is specified. Even a permissive one ("p95 < 2s on a 4G connection") would alert the team if the issue-5 assembly is slow. This should be raised at the issue 5 level but flagged here as a dependency risk.

timeline.empty_state key in the i18n table. The German value is listed as "Noch keine Ereignisse" in the i18n table but the spec elsewhere says "Noch keine Ereignisse." (with a period). The period presence/absence is a consistency question — German convention for this type of UI message typically includes a period. Clarify once and apply consistently across all three locales.

The personId prop on TimelineView is typed as string but the Person domain uses UUID. The spec says let { personId = undefined }: { personId?: string } = $props(). This is correct (TypeScript has no UUID type; string is the right type). However, the AC-PERSON-ID-PROP test should pass a valid UUID-shaped string (not just any string) to reflect realistic usage from issue 10.

Deferred: letter-cluster-under-event. The decision to defer clustering (letters appearing under a linked event) is documented. However, the issue does not specify what happens when a letter has a linked event — will it appear in both its year band and under the event in issue 9's implementation? Or only in its year band? The current spec says "every letter renders once, in its own year band" — this implies that in the global view (issue 7), a letter linked to an event still appears in its own year band only. This is a boundary that issue 9 must respect. The constraint should be in the issue 9 spec, not here, but it should be explicitly documented somewhere.

Recommendations

  • Add a comment to the AC-ORDER and AC-SAME-PRECISION-ORDER tests explaining the component is a pass-through verifier, not a sorter — prevents future developers from introducing client-side sorting to "fix" these tests.
  • Strengthen AC-PERSON-ID-PROP: require the test DTO to have at least one year band and one undated entry.
  • Standardize the period in timeline.empty_state German value.

Open Decisions (none)

The remaining items above are specification clarifications, not genuine tradeoffs requiring human input.

## 📋 Elicit — Requirements Engineer ### Observations Round 1 folded in substantial decisions and the spec is now dense and precise. These are residual ambiguities and a missing NFR that could produce incorrect behavior or implementation confusion. **AC-ORDER and AC-SAME-PRECISION-ORDER together establish a contract but leave the "why" implicit.** Both criteria assert "renders in DTO order." This is correct per the design. But the issue text says "the component is a pass-through, proven by the AC-ORDER test" — yet AC-ORDER verifies that DAY comes before YEAR, which *could* be interpreted as a client-side sort. A developer unfamiliar with the design principle might implement client-side sorting to make the test pass, which would violate the constraint. Recommend adding a comment to the test: `// DTO delivers entries pre-sorted by issue-5 backend; this test verifies the component preserves, not produces, that order.` This is a documentation gap, not a code gap, but it protects against future regressions when a new developer "fixes" sorting. **AC-PERSON-ID-PROP is underspecified.** The criterion says "When `personId` is undefined, `TimelineView` renders the global timeline (all years and entries from the DTO) with no filtering." But it doesn't specify what the test DTO contains — if it's an empty DTO, the test passes vacuously. The test should use a DTO with at least one year band and one undated entry to prove the data flows through. **No NFR for initial load time.** The page loads a potentially large DTO (all timeline events + derived person events + all letters). For an archive with 1,000+ documents spanning 50 years, the DTO could be substantial. No latency NFR is specified. Even a permissive one ("p95 < 2s on a 4G connection") would alert the team if the issue-5 assembly is slow. This should be raised at the issue 5 level but flagged here as a dependency risk. **`timeline.empty_state` key in the i18n table.** The German value is listed as "Noch keine Ereignisse" in the i18n table but the spec elsewhere says "Noch keine Ereignisse." (with a period). The period presence/absence is a consistency question — German convention for this type of UI message typically includes a period. Clarify once and apply consistently across all three locales. **The `personId` prop on `TimelineView` is typed as `string` but the Person domain uses `UUID`.** The spec says `let { personId = undefined }: { personId?: string } = $props()`. This is correct (TypeScript has no UUID type; `string` is the right type). However, the AC-PERSON-ID-PROP test should pass a valid UUID-shaped string (not just any string) to reflect realistic usage from issue 10. **Deferred: letter-cluster-under-event.** The decision to defer clustering (letters appearing under a linked event) is documented. However, the issue does not specify what happens when a letter *has* a linked event — will it appear in *both* its year band and under the event in issue 9's implementation? Or only in its year band? The current spec says "every letter renders once, in its own year band" — this implies that in the global view (issue 7), a letter linked to an event still appears in its own year band only. This is a boundary that issue 9 must respect. The constraint should be in the issue 9 spec, not here, but it should be explicitly documented somewhere. ### Recommendations - Add a comment to the AC-ORDER and AC-SAME-PRECISION-ORDER tests explaining the component is a pass-through verifier, not a sorter — prevents future developers from introducing client-side sorting to "fix" these tests. - Strengthen AC-PERSON-ID-PROP: require the test DTO to have at least one year band and one undated entry. - Standardize the period in `timeline.empty_state` German value. ### Open Decisions _(none)_ The remaining items above are specification clarifications, not genuine tradeoffs requiring human input.
Author
Owner

🚀 Tobias Wendt — DevOps & Platform Engineer

Observations

This issue is a pure frontend route addition. No new Docker services, no new infrastructure, no new environment variables. From an infrastructure standpoint, the surface is minimal. Confirming what I checked and noting one build-time concern.

No new infrastructure. The /zeitstrahl route is served by the existing SvelteKit Node adapter. No additional Docker Compose service, no new port, no new volume. The existing Caddy reverse proxy routes / to the SvelteKit container — the new route is automatically covered. No infra changes needed.

CI: npm run generate:api must run after issue 5 and 6 merge. The spec correctly states that the frontend blocked on issues 5 and 6. The generated types (TimelineDTO, TimelineYearDTO, TimelineEntryDTO) will not exist in $lib/generated/api until the backend is running with --spring.profiles.active=dev and npm run generate:api has been run. In CI this means: the CI pipeline for issue 7's branch must either (a) depend on a backend build artifact from issue 5/6's merge, or (b) have the generated types committed. Looking at how the project handles this: the generated api types are committed to git (standard pattern in this repo). Issue 6 must commit the generated types before issue 7's branch is cut. The implementor of issue 7 must pull issue 6's merge before starting.

E2E test frontend/e2e/zeitstrahl.spec.ts. Playwright E2E tests run against the full Docker Compose stack in CI. The new spec will run with the existing E2E infrastructure — no changes to docker-compose.ci.yml needed. The page.setViewportSize({ width: 320, height: 812 }) call is supported by the existing Playwright Chromium configuration.

No new env vars. The issue introduces no configuration — GET /api/timeline is a standard authenticated endpoint on the existing backend URL. Nothing to add to .env.example.

Pre-commit hook note. A fresh worktree for this issue will need npm install in frontend/ before the first commit (pre-commit hook runs cd frontend && npm run lint). This is documented in project memory but worth noting: if the implementor creates a worktree, they must npm install before committing.

Recommendations

  • No infrastructure changes needed.
  • Ensure issue 6's branch is merged and npm run generate:api has been committed before cutting the issue 7 branch.

Open Decisions (none)

Infrastructure is a no-op for this issue.

## 🚀 Tobias Wendt — DevOps & Platform Engineer ### Observations This issue is a pure frontend route addition. No new Docker services, no new infrastructure, no new environment variables. From an infrastructure standpoint, the surface is minimal. Confirming what I checked and noting one build-time concern. **No new infrastructure.** The `/zeitstrahl` route is served by the existing SvelteKit Node adapter. No additional Docker Compose service, no new port, no new volume. The existing Caddy reverse proxy routes `/` to the SvelteKit container — the new route is automatically covered. No infra changes needed. **CI: `npm run generate:api` must run after issue 5 and 6 merge.** The spec correctly states that the frontend blocked on issues 5 and 6. The generated types (`TimelineDTO`, `TimelineYearDTO`, `TimelineEntryDTO`) will not exist in `$lib/generated/api` until the backend is running with `--spring.profiles.active=dev` and `npm run generate:api` has been run. In CI this means: the CI pipeline for issue 7's branch must either (a) depend on a backend build artifact from issue 5/6's merge, or (b) have the generated types committed. Looking at how the project handles this: the generated `api` types are committed to git (standard pattern in this repo). Issue 6 must commit the generated types before issue 7's branch is cut. The implementor of issue 7 must pull issue 6's merge before starting. **E2E test `frontend/e2e/zeitstrahl.spec.ts`.** Playwright E2E tests run against the full Docker Compose stack in CI. The new spec will run with the existing E2E infrastructure — no changes to `docker-compose.ci.yml` needed. The `page.setViewportSize({ width: 320, height: 812 })` call is supported by the existing Playwright Chromium configuration. **No new env vars.** The issue introduces no configuration — `GET /api/timeline` is a standard authenticated endpoint on the existing backend URL. Nothing to add to `.env.example`. **Pre-commit hook note.** A fresh worktree for this issue will need `npm install` in `frontend/` before the first commit (pre-commit hook runs `cd frontend && npm run lint`). This is documented in project memory but worth noting: if the implementor creates a worktree, they must `npm install` before committing. ### Recommendations - No infrastructure changes needed. - Ensure issue 6's branch is merged and `npm run generate:api` has been committed before cutting the issue 7 branch. ### Open Decisions _(none)_ Infrastructure is a no-op for this issue.
Author
Owner

Visual specs (on main, commit ddb1ec4d — HTML, open in a browser):

  • Canonical design — Concept A "Der Lebensfaden": docs/specs/zeitstrahl-final-spec.html — anatomy of the vertical axis, the three grouping modes (Datum / Ereignis / Thema), and the full all-cases preview (empty years → ≤3 letters → hundreds via year-strip + sparkline → undated bucket), responsive (desktop centred axis / phone left axis / 35 % Lebensweg rail), tokens, impl-ref.
  • Exploration that led to it (A/B/C, why ≠ search list): docs/specs/zeitstrahl-global-concepts.html — incl. the §5 letter-grouping model (date default + curated-event clusters + tag colour chips) and the §5 curation entry points.

Complements the text design spec already referenced in the body (docs/superpowers/specs/2026-06-07-family-timeline-design.md).

**Visual specs** (on `main`, commit `ddb1ec4d` — HTML, open in a browser): - **Canonical design — Concept A "Der Lebensfaden":** [`docs/specs/zeitstrahl-final-spec.html`](https://git.raddatz.cloud/marcel/familienarchiv/src/branch/main/docs/specs/zeitstrahl-final-spec.html) — anatomy of the vertical axis, the three grouping modes (Datum / Ereignis / Thema), and the full all-cases preview (empty years → ≤3 letters → hundreds via year-strip + sparkline → undated bucket), responsive (desktop centred axis / phone left axis / 35 % Lebensweg rail), tokens, impl-ref. - **Exploration that led to it (A/B/C, why ≠ search list):** [`docs/specs/zeitstrahl-global-concepts.html`](https://git.raddatz.cloud/marcel/familienarchiv/src/branch/main/docs/specs/zeitstrahl-global-concepts.html) — incl. the §5 letter-grouping model (date default + curated-event clusters + tag colour chips) and the §5 curation entry points. Complements the text design spec already referenced in the body (`docs/superpowers/specs/2026-06-07-family-timeline-design.md`).
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#779