feat(timeline): global /zeitstrahl timeline (Concept A) — #779 #831
Reference in New Issue
Block a user
Delete Branch "feat/issue-779-zeitstrahl"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Global
/zeitstrahltimeline — Concept A "Der Lebensfaden"Closes #779. Builds the frontend route + component tree that renders the existing
GET /api/timeline→TimelineDTO, in "Datum" (chronological) mode, covering the full Concept-A layout fromdocs/specs/zeitstrahl-final-spec.html. Presentation-only: entries render in DTO order, never re-sorted/re-bucketed. No backend change, nogenerate:api, no migration, no newErrorCode.What's here
document/timeline.ts→$lib/shared/utils/monthBuckets.tssolib/timeline/never importslib/document/. The/api/documents/densityglue (fetchDensity/buildDensityUrl) stays put; the 3 density components + the relocated spec are re-pointed.grep -rn lib/document frontend/src/lib/timeline/→ zero.frontend/src/lib/timeline/:TimelineView(orchestrator,<ol>+ gap-fold + undated + empty-state, derived-safe{#each}keys,personIdseam),YearBand(sticky<h2>, cards-vs-strip, DTO order, desktop alternating axis),EventPill,WorldBand,LetterCard,YearLetterStrip,GapSpan,eventCardConfig,timelineDensity,test-factories.Sparklineprimitive; route/zeitstrahl/+page.{server.ts,svelte}(SSR-first, mirrorsstammbaum); nav link + 14 i18n keys (de/en/es).e2e/zeitstrahl.spec.ts(nav smoke,<main>containment, 320px no-overflow on a seeded long-named letter).Requirement coverage
All REQ-001..027 implemented and tested (see
.specify/rtm.md, featurezeitstrahl-global-view). 18 commits, atomic, red/green TDD.monthBuckets,timelineDensity,eventCardConfig,document/timeline,zeitstrahl/page.server).*.svelte.spec.ts× 7,e2e/zeitstrahl.spec.ts) — not run locally by policy.REQ-019 — contrast (manual, recorded)
The HISTORICAL band label renders in
text-ink-2(the spec's documented fallback), not raw slate:#4b5563on canvas#f0efe9→ 6.6:1 (AA ✓)#9ca3afon canvas#010e1e→ ≈7.6:1 (AAA ✓)The raw
tag-slate(#607080) measures ≈4.4:1 on canvas in light — below 4.5:1 — which is exactly why the band text falls back totext-ink-2per REQ-019. Slate is used only for the decorative◍glyph, which carries ansr-only"Weltgeschehen" label (color never the sole cue, WCAG 1.4.1).REQ-021 — escaping guard
grep -r '@html' frontend/src/lib/timeline/→ zero. All OCR/import-derived text (title,senderName,receiverName) renders via default{...}escaping +whitespace-pre-line.Out of scope (follow-ups filed under milestone #14)
Grouping toggle (#827), leading/trailing archive-range decades (#828), curator quiet-span labels (#829), per-month strip drill-down (#830).
🤖 Generated with Claude Code
Single archive letter: sender → receiver (Unbekannt fallback for empty names, REQ-014), title, precision date chip via timelineDateLabel (omitted when null, REQ-013), linking to exactly /documents/{documentId} with no target (REQ-023). 44px touch target enforced inline + focus-visible ring (REQ-020). OCR/import text via {...} escaping + whitespace-pre-line, no {@html} (REQ-021). Refs #779 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>A thin dashed span rendering '{from}–{to} · keine Einträge', collapsing to a single year when the run has length 1 (REQ-015). Refs #779 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>One <section> per year with a sticky <h2> at top:4rem (REQ-006). Events render in DTO order as pills/bands; letters render as individual cards while <= 12 (REQ-011) or collapse to one density strip above that (REQ-012); DTO order is never re-sorted (REQ-003). Letters carry an alternating data-side for the centered desktop axis (REQ-004); single left column on phone (REQ-005). Derived-safe {#each} key. Refs #779 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Renders year bands in DTO order with interior empty-year runs folded into one GapSpan (REQ-015), a single <ol> in chronological DOM order (REQ-006), the undated bucket via {#if} (REQ-016), and a calm empty state (REQ-017). personId is a declared seam (issue #10), undefined here, never passed to leaf cards (REQ-025). Centered desktop spine / left phone spine via scoped CSS. Owns no <main>. Refs #779 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>SSR-first load fetches GET /api/timeline via createApiClient (auth cookie forwarded), no query params for the global view (REQ-001), returns { timeline } with no client-side fetch (REQ-002); 401 -> /login, any other non-ok -> error(status, getErrorMessage(...)), never raw JSON, no PII logged (REQ-022). The page renders <TimelineView> under the layout's <main>. Adds the Zeitstrahl nav link (desktop + mobile) and 'timeline' to the eslint routes boundary allow-list so the route may import the domain. Refs #779 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>Move the per-entry {#each} key logic into a shared entryKey.ts so the undated bucket in TimelineView can reuse it. No behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>📋 Requirements Engineer — PR Review
Verdict: 🚫 Changes requested
One requirement is contradicted by the code (REQ-024). Everything else traces end-to-end. RTM rows were flipped to
Done, but the issue body's Traceability still saysPlanned(RTM is the committed source of truth, so that's fine — noting for the record).Per-REQ traceability
Done?+page.server.ts,TimelineViewpage.server.test.ts,TimelineView…#person-id-noop+page.server.tspage.server.test.ts(assertsGET('/api/timeline'), no client fetch)TimelineView/YearBandpass-throughYearBand…#DTO order,TimelineView…#ol-orderYearBandCSS +data-side#alternating sides+ e2ee2e#no-overflow-320(seeded long names)<ol>, sticky<h2>top:4remTimelineView…#ol,YearBand…#stickyeventCardConfigEventPill…,eventCardConfig.specEventPillWorldBandWorldBand…#range pillWorldBand…#degradesYearBandYearLetterStripLetterCard…#precision,#null→no chipLetterCardTimelineView/GapSpan#empty-stateEventPill/WorldBand/TimelineViewtext-ink-2fallbackLetterCard#touch-target{@html}+page.server.ts#hrefde#person-id-nooptimelineDensitytimelineDensity.specBlockers
frontend/messages/en.json:1044-1048andes.json:1044-1048contradict REQ-024. REQ-024 and the issue's i18n table state, verbatim: "The EN/EStimeline.derived.*andtimeline.layer.*values intentionally carry the German term (documented MVP decision, not an oversight)." The table mandates de = en = es =Weltgeschehen/Familie/Geburt/Tod/Heirat. The code ships en =World events / Family / Birth / Death / Marriageand es =Acontecimientos mundiales / Familia / Nacimiento / Fallecimiento / Matrimonio. At runtime in en/es locale a HISTORICAL band would read "World events" and a birth pill "Birth" — directly violating the documented decision. TheeventCardConfig.spec.tsGerman assertions pass only because the test locale isde; there is no test pinning the en/es values, so the contradiction is invisible to the suite. Fix: set those 10 keys to the German term inen.json+es.json; add an assertion (or a static check) that pins en/es to the German value so this can't regress. NB: the other timeline keys (nav_zeitstrahl,timeline_heading,timeline_empty_state,timeline_undated_section,timeline_unknown_person,timeline_gap_empty,timeline_letters_count,timeline_strip_expand,timeline_range_aria) are correctly localized per the table.Suggestions
Planned; the committed RTM is authoritative and readsDone, so no action required, but consider editing the issue body for consistency.🛠️ Developer (Felix Brandt) — PR Review
Verdict: ⚠️ Approved with concerns
Clean Svelte 5 throughout:
$derived/$derived.by(never$effect-to-compute), keyed{#each}everywhere, every component under 60 lines and named for one visual region, props are domain-named (entry,year,letters) notdata. The shared-extraction refactor is correct —monthBuckets.tsmoved to$lib/shared/utils/, the 4 import sites re-pointed, the spec relocated, andlib/timeline/importssharedonly. TDD evidence is strong: per-REQ spec files with descriptive names, factories mirror the real DTO. TheentryKeyderived-safe composite (kind + eventId ?? documentId ?? derivedType+linkedPersonIds) correctly prevents theEVENT:undefinedcollision for two derived events in one band, and there's a test for it.Blockers
Concerns
EventPill.svelte:51— dangling edit link.href="/zeitstrahl/events/{entry.eventId}/edit"points at a route that does not exist (frontend/src/routes/zeitstrahl/events/is absent; curator forms are issue #9, explicitly out of scope per the issue body). REQ-008 only requires the affordance to appear wheneventId != null— it does not specify the target — so this isn't a REQ violation, but clicking it 404s today. TheEventPill.spec.tsonly asserts the hreftoContain(EVENT_ID), so the broken target is "tested" without being verified to resolve. Options: point it at the real edit surface once #9 lands, or render the affordance as a disabled/non-link placeholder until then. At minimum, leave aRefs #9comment on the line so the dead link is intentional and tracked.WorldBand.svelte:28— inlinestyle="color: var(--c-tag-slate)". A raw inline color on the glyph. It'saria-hiddendecorative and the PR documents the contrast rationale, so it's acceptable, but a token utility class would be more consistent with the rest of the tree (see UI/UX). Minor.What's done well
+page.server.tsmirrorsstammbaumexactly:!result.response.okguard,result.data!after the ok check, 401→redirect, mappedgetErrorMessage(extractErrorCode(...))— textbook. Noconsole.logof the PII payload on any path.LetterCardinlines the flex/min-height so the 44px target holds before CSS loads — a thoughtful reliability touch with a comment explaining why (not what).🧪 Tester (Sara Holt) — PR Review
Verdict: ⚠️ Approved with concerns
Test quality is high. Each REQ has a named, behavior-describing test at the right pyramid layer: pure logic (
monthBuckets,timelineDensity,eventCardConfig,entryKeyvia component) as node/component unit tests; the SSRloadtested directly with a mockedcreateApiClient(no browser); component DOM viavitest-browser-svelte; and a thin E2E (e2e/zeitstrahl.spec.ts) for the 320px no-overflow guarantee on seeded populated data and<main>containment — exactly the seam the spec asked for. Factories mirror the real wire shape (no phantomyear/description/snippet). Edge cases are covered: null RANGE end, empty sender/receiver, UNKNOWN→undated, single-vs-multi gap fold, the no-double-null-key invariant, density boundary at exactly 12 vs >12.Blockers
Concerns (coverage gaps that hide the REQ-024 contradiction)
eventCardConfig.spec.tsasserts'Geburt'/'Weltgeschehen'/'Familie', but those pass only because the suite default locale isde. There is no test that loads theen/esmessage and asserts it equals the German term. That is precisely why the REQ-024 contradiction (en="Birth", es="Nacimiento", …) slipped through green. Add a locale-pinning assertion — e.g. import the rawen.json/es.jsonand asserttimeline_layer_world === 'Weltgeschehen'and the fivetimeline_derived_*German values for both locales. A test like this should have been red first under REQ-024.EventPill.spec.ts:67asserts the edit href onlytoContain(EVENT_ID)— it never verifies the link resolves to a real route. Combined with the missing/zeitstrahl/events/...route (Developer concern), this is a "green but broken" link. If the affordance is meant to be live, an E2E click-through would catch the 404; if it's a deliberate stub for #9, the test should document that.What's done well
createPerson+ documentPUT) with 25+char names rather than asserting against a fragile fixture — deterministic and matches REQ-005's measurable AC.🔐 Security (Nora "NullX") — PR Review
Verdict: ✅ Approved
This is a read-only presentation feature and the threat model (Information disclosure + Tampering→XSS) is handled correctly. Checked the whole
lib/timeline/tree and the route.What I verified
grep -r '@html' frontend/src/lib/timeline/ frontend/src/lib/shared/primitives/Sparkline.svelte→ zero. Every OCR/import-derived string (title,senderName,receiverName) renders through Svelte's default{...}escaping, withwhitespace-pre-linefor line breaks rather than raw HTML. NoinnerHTML/{@html}anywhere in the new tree. ✅+page.server.ts→ props. Nofetch/onMountin any component; the auth cookie is forwarded bycreateApiClient(fetch). API routes are never exposed to the browser. ✅+page.server.tshas noconsole.log/logging of the timeline payload on any branch — and it carries a comment stating the payload is PII and must not be logged. Error path returns a mappedgetErrorMessage(...), never raw backend JSON (REQ-022). ✅GET /api/timeline(@RequirePermission(READ_ALL)); 401→/login, 403→mapped FORBIDDEN. No mutating endpoint, no newErrorCode, no upload, no secret. The DTO is a purpose-built view (TimelineEntryDTO), never a rawDocument/Person/AppUser— IDOR surface is minimal and the only outbound link is/documents/{documentId}built from a UUID, no free-text. ✅LetterCardhref is exactly/documents/{uuid}, internal, notarget="_blank"(REQ-023, asserted in test) → norel="noopener"gap. ✅Suggestions
EventPilledit link to/zeitstrahl/events/{id}/edit(raised by Developer/Tester) is not a security issue — internal, UUID-only, 404s harmlessly — just noting I looked at it.No blockers from a security standpoint.
🚀 DevOps (Tobias Wendt) — PR Review
Verdict: ✅ Approved
Pure frontend change — no infrastructure surface. Confirmed against the Do-Not-Touch list (constitution §4) and CI guards.
What I checked
package.jsonunchanged → no §5.1 ADR needed. ✅frontend/src/lib/generated/api.tsorfrontend/src/lib/paraglide/. The PR explicitly runs nogenerate:api(no backend change), which is correct here. ✅{@html}raw-data guard (#666) and theactions/*-artifact@v3pin are not weakened; no new gate added (REQ-021 is review-enforced, consistent with the issue's stated decision).eslint.config.jsonly adds'timeline'to the boundaries allow-list — a tightening, not a weakening. ✅e2e/zeitstrahl.spec.tsseeds its own data through the real API per-run (no shared-staging dependency, no order coupling), and the no-overflow assertion runs against populated data. Usesexpect.toPass()polling rather thansleep. Good. ✅feat/issue-779-zeitstrahlhas no+(vitest-browser-safe); nofamilienarchiv-*/data/copy committed (§4.7). ✅Suggestions
stamp()usesnew Date().toISOString()for uniqueness — fine for seeding, but two persons created in the same millisecond would collide; negligible risk for a single-run seed, no action needed.No blockers.
🎨 UI/UX (Leonie Voss) — PR Review
Verdict: ⚠️ Approved with concerns
Strong accessibility and responsive foundation, mobile-first as required. Semantic structure is correct: single
<ol>chronology, each band a<section>with a real<h2>(stickytop-16to clear the 64px nav), the<main>landmark is owned by+layout.svelte:134(component correctly does not declare its own). Redundant non-color cues are present everywhere a glyph appears —<span aria-hidden="true">†</span><span class="sr-only">Tod</span>pattern inEventPill,WorldBand, and the range pill carriesaria-label="Zeitraum: 1914 bis 1918"(REQ-009/018). Touch targets are honoured:LetterCardlinkmin-height:44px(inlined so it holds before CSS loads — nice), strip expand togglemin-height:44px+aria-expanded+ keyboard-focusable (REQ-012/020). Focus rings usefocus-visible:ring-2 focus-visible:ring-brand-navyconsistently (REQ-020). Contrast (REQ-019) is recorded in the PR body for both themes with the documentedtext-ink-2fallback — exactly the AC.Blockers
Concerns
WorldBand.svelte:28—style="color: var(--c-tag-slate)"raw inline color. Brand rule is to use semantic token utilities, not inline hex/var on elements. It's on anaria-hiddendecorative glyph and the PR proves the slate ≈4.4:1 is why the label text falls back totext-ink-2, so contrast is safe — but please move the glyph color to a token class (e.g. atext-*utility mapped to the slate token) so dark-mode remapping stays centralized. Low severity.aria-label("{count} Briefe"). REQ-018's redundant-cue rule is about layer identity (which is met), and the strip's count is shown as text, so this is acceptable — but consider a future per-bar title/tooltip with the month count for the dense-year case. Suggestion only.What's done well
timeline.empty_statemessage, not a blank screen (REQ-017); gap runs fold into an oriented "{from}–{to} · keine Einträge" span (REQ-015). Dual-audience friendly: large serif names, sans chrome, generous targets for the 60+ readers, dense strip for the millennial scanners.🏛️ Architect (Markus Keller) — PR Review
Verdict: ✅ Approved
Boundaries are respected and the refactor is the right shape. The shared-extraction is exactly the call I'd make: pure month-bucket math promoted to
$lib/shared/utils/monthBuckets.ts, with the/api/documents/densityglue (fetchDensity/buildDensityUrl) left behind indocument/timeline.ts.lib/timeline/importssharedonly —grep -rn lib/document frontend/src/lib/timeline/→ zero (constitution §1.4). The newtimelinedomain lives in its ownfrontend/src/lib/timeline/package and is added to theeslint.config.jsboundaries allow-list in the same change. The generatedMonthBucketschema type is reused rather than hand-redefined, keeping the document chart's backend coupling intact. No ADR is required (reversible, no new pattern, consumes existing ADR-040/043/039) — and the issue flagged this for my confirmation, which I give.Documentation currency (my gate — all present)
CLAUDE.mdroute table anddocs/architecture/c4/l3-frontend-3c-people-stories.puml(addedzeitstrahlcomponent + bothReledges).frontend/CLAUDE.mdstructure updated (routes/zeitstrahl,lib/timeline/, themonthBuckets/Sparklinemoves).docs/GLOSSARY.md("Lebensweg" added; Zeitstrahl/derived-event already defined).lib/document/README.md+lib/shared/README.mdrecord thetimeline.ts→monthBuckets.tsrelocation..specify/rtm.mdREQ-001..027 rows added under featurezeitstrahl-global-view.Architectural observations (not blockers)
personIdprop seam onTimelineView(REQ-025) is the correct extension point for the per-person Lebensweg rail (#10) without over-building it now — KISS-compliant, no premature abstraction. Good restraint.entryKeyis a thin shared module rather than duplicated inline inYearBandandTimelineView— justified by two real callers + the derived-null-key correctness need, not abstraction-for-its-own-sake.No boundary leaks, no superseded-ADR violation, no Do-Not-Touch breach. (The REQ-024 i18n issue is the Requirements Engineer's blocker — not an architecture concern.)
✅ REQ-024 blocker resolved
The Requirements Engineer's blocker (en/es shipping localized timeline layer/derived labels instead of the German-only values) was a deliberate decision reversal, not a regression. Resolved by updating the contract rather than reverting:
timeline.layer.*/timeline.derived.*values per locale. These feedsr-only/ariatext, so EN/ES screen-reader users must hear their own language. The old "German-only MVP" note is removed.messages.spec.ts#timeline layer/derived labels are localized per localeasserts the de/en/es values, so they can't silently drift back to the German source strings (this is also why the contradiction previously went green — the only locale-aware test ran inde).Commits:
4a6fd770(translations),ce1b4c74(pin test),d3f93c55(rtm), pushed to headd3f93c55.The two non-blocking concerns are acknowledged: the
/zeitstrahl/events/[id]/editlink is intentionally left for the curator-forms follow-up (issue #9 / #779 out-of-scope), and theWorldBandinline glyphstylecan move to a token utility in a follow-up.