Timeline: per-person Lebensweg on Person detail #782
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Milestone: Zeitstrahl — Family Timeline
Spec:
docs/superpowers/specs/2026-06-07-family-timeline-design.md§ "Concept & UX" / "Frontend"Depends on (hard, blocked-by):
birthDate/deathDate+ precision, derived person-events (Geburt/Tod) are empty and the Lebensweg is empty.?personId=filter — defines what "their events" means (see Data scope).<TimelineView>component — this issue only embeds it. #7 MUST ship with explicit ACs for both modes:personId-self-load AND pre-loadedtimeline-prop embedding. The pre-loaded prop is required for SSR embedding here. Do not start this issue until #7's spec confirms both signatures are in its acceptance criteria.Scope
Embed a Lebensweg timeline on the Person detail page by reusing the existing
<TimelineView>filtered to that person. This is pure frontend composition — no new backend, no new endpoint, no new entity, no migration, no new route, no new env/infra. Per-person is justGET /api/timeline?personId=…; do not add a/api/persons/{id}/timelineshim. All timeline business logic stays inlib/timeline/; the Person domain depends on Timeline's published component, never the reverse.Resolved Decisions
Data flow: SSR prop (Option A). The timeline is loaded in
persons/[id]/+page.server.tsas an additional parallelapi.GET('/api/timeline', { params: { query: { personId: id } } })call and passed down as atimelineprop. Rationale: consistent with every other card on the page (all data flows server → prop), fully SSR (no loading spinner, no client-side API exposure), and preserves the authenticatedcreateApiClient(fetch)auth-cookie forwarding. Do NOTonMount(fetch('/api/timeline'))client-side — that breaks SSR auth-cookie forwarding (documented anti-pattern). The corresponding dual-input contract for<TimelineView>(accept apersonIdfor self-load on the global route or pre-loaded data when embedded) is locked at #7 — design it there now, do not retrofit.Empty state: hide the card (default). For a person with zero dated items, the Lebensweg card is omitted entirely — mirrors the existing Geschichten
{#if data.geschichten.length > 0}precedent. Rationale: cleanest page for the many persons who'll have only a birth year; zero cost; consistent UX. Never render an empty bordered card.English translation for
person_lebensweg_heading: "Life Journey". Rationale: closest to the German literal meaning ("life's path/journey"), distinct from Geschichten (stories) above it, more personal than "Timeline" (too generic), less confusing than "Life Story" (too close to Geschichten). Spanish: "Camino de Vida". German primary: "Lebensweg".{#if}emptiness check: inspect actual entry counts, not justyears.length. The assembly endpoint (#5) may legitimately return year bands with emptyentries: []arrays (e.g. during incremental assembly). The visibility guard must count real entries:{#if data.timeline.years.some(y => y.entries.length > 0) || data.timeline.undated.length > 0}. Neveryears.length > 0alone — that would show an empty bordered card when all year bands are empty.LebenswegCard.sveltewrapper: inline unless page exceeds ~100 template lines. The card chrome is 4 lines of Tailwind + a heading — not worth a separate file unless+page.sveltebecomes unwieldy. Check actual line count when implementing; only extract if the template portion exceeds ~100 lines.No
canWriteprop on the Lebensweg card. The card is read-only by spec. "Edit at source" links point to the Person/relationship edit screens which already enforceWRITE_ALL. Do not copydata.canBlogWrite(which is a pre-existing latent bug in line 107 of+page.svelte—canWrite={data.canBlogWrite ?? false}reads a non-existent field).Data scope (asserted against the rendered embed; contract owned by #5)
A person's Lebensweg includes:
SPOUSE_OFedge and must surface on both spouses' Lebenswege);TimelineEvent.persons;Entries are year-banded and use precision-aware date labels.
Cross-issue note for #5:
TimelineService.getForPerson(UUID personId)should callPersonService.getById(personId)first (throwing 404 if absent), not silently return empty. This prevents non-existentpersonIdvalues from returning an empty timeline to direct API callers. From the Person detail page this is harmless (page already 404s), but correct for the API contract.Cross-issue note for #5 (AC3 symmetry): Add to #5's acceptance criteria: "Given persons A and B with a
SPOUSE_OFrelationship,GET /api/timeline?personId=AandGET /api/timeline?personId=Bboth return the Heirat event." If #5 doesn't test symmetry, this embed cannot reliably verify AC3.UI / placement
rounded-sm border border-line bg-surface shadow-sm p-6, wrapped in the sibling-consistent<div class="mt-6">.text-xs font-bold uppercase tracking-widest text-ink-3 mb-5), rendered as the section<h2>.persons/[id]/only if+page.sveltetemplate exceeds ~100 lines after the addition — name itLebenswegCard.svelte(visible region name), not…Wrapper. Do not put timeline logic in it; entries render inside<TimelineView>. Default to inlining in+page.svelte.<TimelineView personId={person.id} timeline={data.timeline} />. Neverdata/item/props.overflow-y-autobox (scroll-within-scroll traps 60+ phone users).font-serif(Tinos). Date label strings produced bydateLabel.ts(ca. 1914,Sommer 1914,Ohne Datum) are UI metadata and usefont-sans(Montserrat). The "LEBENSWEG" heading is UI chrome —font-sans. Never applyfont-serifto date labels or section headings.<h2>Lebensweg → year bands as<h3>under it; semantic list markup for entries;focus-visible:ring-2 focus-visible:ring-brand-navyon any "edit at source" links (do not rely on browser defaults). Within the timeline, person names and event titles usefont-serifper the spec.dateLabel.ts):ca. 1914,Sommer 1914,Ohne Datum— never a fabricated01.01.1914for a year-only date. The embed must not bypass it.WRITE_ALL), not a timeline-event editor. Curator event edit/delete is out of scope here (that's #9).LetterCardcomponent (from #7) truncates withtruncateorline-clamp. If it doesn't, flag against #7.i18n
person_lebensweg_headingLebenswegLife JourneyCamino de VidaGerman is primary per project convention.
Security
Read-only re-projection of data already authorized for display on this page (birth/death dates, letter sender/receiver names already appear via
PersonCard,PersonDocumentList,CoCorrespondentsList). Permissions are global (READ_ALL), not per-record — no new authorization boundary, no IDOR concern, no@RequirePermissionwork. A 403/503 on the timeline sub-fetch must not surface a raw backend message — fall back to an empty timeline (?? { years: [], undated: [] }), neverresult.data!. The graceful-degradation fallback correctly hides the failure from the user.Acceptance criteria
person_lebensweg_headingin de/en/es with translations "Lebensweg" / "Life Journey" / "Camino de Vida".Tasks
persons/[id]/+page.server.tsPromise.all(8th parallelapi.GET('/api/timeline', { params: { query: { personId: id } } })); default to{ years: [], undated: [] }on!response.ok; expose asdata.timeline.persons/[id]/+page.svelte(right column, last), conditionally rendered with{#if data.timeline.years.some(y => y.entries.length > 0) || data.timeline.undated.length > 0}, embedding<TimelineView personId={person.id} timeline={data.timeline} />. Only extractLebenswegCard.svelteif template exceeds ~100 lines.person_lebensweg_headinginmessages/{de,en,es}.json: de="Lebensweg", en="Life Journey", es="Camino de Vida".h2Lebensweg →h3year) and no nested scroll container.focus-visible:ring-2 focus-visible:ring-brand-navyto any "edit at source" links.dateLabel.tsoutput strings render infont-sans, notfont-serif.Tests
Server load (
persons/[id]/+page.server.spec.ts, Vitest node):Promise.allanddata.timelineis returned on success (assert shape).personResultfailure still 404s the page (existing behavior unchanged by the new call).response.ok = false) → page still loads,data.timelinedeep-equals{ years: [], undated: [] }(assert the exact shape, not just that no exception was thrown).page.server.spec.tsuses 7mockResolvedValueOncechains. Adding an 8th means every existing test in that file must get an additional.mockResolvedValueOnce({ response: { ok: true }, data: { years: [], undated: [] } })for the timeline call. Failing to do this will cause the 8th slot to returnundefinedand silently mask regressions. Do a systematic pass over all existing tests and update every chain. Flag this in the PR description.Component (
persons/[id]/page.svelte.test.ts,vitest-browser-svelte, real DOM):getByRole('heading', { name: /lebensweg/i })) whendata.timelinehas entries.{#if length > 0}pattern).<TimelineView>receivespersonId={person.id}— assert via a rendered person-scoped entry, not by inspecting props.Test factories:
makeTimeline(overrides?)— defaults to{ years: [], undated: [] }(empty state).makeTimelineWithEntries()— returns one populated year band with at least one entry, so populated-state tests stay one-liners without constructing a 10-field DTO inline.No new Playwright E2E — the per-person timeline is a re-render of the global timeline (covered by #7/#11). The axe/dark-mode a11y pass on
/persons/[id]belongs to #11's polish.Targeted single-file runs locally (browser specs ~3s); full sweep to CI.
Ops note
The 8th parallel call rides the existing Spring Boot trace/metrics instrumentation — no new observability work. If, once #5 lands,
GET /api/timeline?personId=p95 is materially slower than the other person sub-fetches (it can be the heaviest read on the page and gates first paint when slowest), surface it back to #5 as an indexing problem (documents.document_date,timeline_event_persons.person_id) — do not paper over it with a client-side lazy-load here.Post-merge monitoring: After #5 + #10 land, check the Spring Boot request-duration histogram:
http_server_requests_seconds{uri="/api/timeline", quantile="0.95"}. The p95 for?personId=should stay under 500ms (matching the existing load test gate). If it exceeds it, that is an index ticket for #5, not a lazy-load workaround here.🏛️ Markus Keller — Application Architect
Observations
This issue is frontend composition only — and it is well-scoped.
The "no new backend, no new endpoint" constraint is correct and should not be relaxed. The per-person view is
GET /api/timeline?personId=…, which is already the right endpoint shape given thatGET /api/timelineacceptspersonIdas an optional filter.ADR gap — but this issue may not be the right place to write it.
The milestone spec (§ "An ADR may be warranted for the new
timeline/domain + entity") flags that an ADR belongs somewhere in this milestone. Issue #10 (this issue) is a pure frontend composition task; the ADR belongs in issues #2/#3 where thetimeline/backend package is introduced. Confirm that the ADR is in-flight there. If neither #2 nor #3 have opened that ADR, it must happen before #10 is merged — the new package is already a structural fact by the time #10 lands.Domain dependency direction is correct.
The spec states: "All timeline business logic stays in
lib/timeline/; the Person domain depends on Timeline's published component, never the reverse." This aligns with the project's domain boundary rules. However,<TimelineView>receivingpersonId={person.id}must not import anything from$lib/person/— direction must stay Person → Timeline, not bidirectional. Verify in the PR thatTimelineView.svelteand its sub-components have zero imports from$lib/person/.Documentation requirements triggered by this issue:
Per the doc-update table, this issue adds a new card section (not a new route), but it touches
persons/[id]/+page.svelteand embeds a component from a new domain (lib/timeline/). Required updates:docs/architecture/c4/l3-frontend-3c-people-stories.puml— add the Lebensweg card + TimelineView referenceCLAUDE.md— the route table entry forpersons/[id]should note the timeline embed once the/zeitstrahlroute is also added (that belongs to #7, but the person page reference should be consistent)docs/GLOSSARY.md— "Lebensweg" is a new domain term; add it alongside "Zeitstrahl"No new Docker service, no new
ErrorCode, no newPermission— those parts of the doc table are silent for this issue.The
canBlogWritelatent bug.The issue body correctly identifies the
canWrite={data.canBlogWrite ?? false}on line 107 of+page.svelteas a pre-existing bug —canBlogWriteis not exposed by+page.server.ts. The Lebensweg card is read-only so it does not need acanWriteprop at all, which is the right call. Do not silently propagate the bug; the PR description should note it explicitly (it is not the issue's responsibility to fix it, but it should not be made worse).The 8th
Promise.allslot and test chains.The issue body already flags this in detail — every existing
mockResolvedValueOncechain inpage.server.spec.tsgains an 8th slot. I counted 5 test cases in that file, each with a 7-chain mock; the issue correctly requires updating all 5. This is a genuine regression risk if missed. The PR description must call this out explicitly so the reviewer checks.Recommendations
timeline/domain. Flag it in the PR if absent.docs/GLOSSARY.mdentry for "Lebensweg" (German: life's path; the per-person chronological view on the Person detail page) alongside the "Zeitstrahl" entry that will come from #7.l3-frontend-3c-people-stories.pumlto add theTimelineViewembed reference on the persons/[id] page.TimelineViewimports zero symbols from$lib/person/."👨💻 Felix Brandt — Senior Fullstack Developer
Observations
The existing
+page.server.tshas 7 parallel calls; this adds an 8th.I read the actual file. The current
Promise.alldestructures 7 results. The issue correctly mandates adding the timeline call as the 8th. The key implementation point: the timeline result must use?? { years: [], undated: [] }(not?? []), because the TypeScript types from#6will typedata.timelineasTimelineDTO, not an array. Using the wrong fallback will cause a type error at the{#if data.timeline.years.some(...)}call site.The emptiness guard expression is non-trivial — extract it.
The issue mandates:
{#if data.timeline.years.some(y => y.entries.length > 0) || data.timeline.undated.length > 0}. This is business logic in template markup (rule violation from my style guide). Extract it as a$derivedin+page.svelte:Then the template reads
{#if hasLebenswegEntries}. This also makes the component test straightforward to read.Inline vs.
LebenswegCard.sveltethreshold.The current
+page.svelteis 113 lines. Adding the Lebensweg card section will bring it to approximately 123-130 lines depending on the card markup. The issue sets the threshold at ~100 template lines. Since the script block is ~50 lines and the template is ~63 lines, adding 12-15 lines of template puts the template portion at ~75-78 lines — under the 100-line split threshold. Keep it inline; do not extract. Recheck at PR time: if thehasLebenswegEntriesderived and the card markup push template past 100, extract then.Component test: assert behavior, not props.
The issue correctly states: "
<TimelineView>receivespersonId={person.id}— assert via a rendered person-scoped entry, not by inspecting props." This is the right approach. In thepage.svelte.test.ts, the test should render with adata.timelinethat has a populated entry and assert the heading appears — that confirms the composition works. Do not try to inspect Svelte internal props.The
baseDatafactory inpage.svelte.test.tsneeds updating.The existing
baseData()factory (line 16-37) does not include atimelinefield. Every existing test will needtimeline: { years: [], undated: [] }added tobaseData's defaults so existing tests do not break when the prop is required. This is the component-test parallel of the 8th mock-slot problem.Test factory naming.
The issue mandates
makeTimeline()andmakeTimelineWithEntries(). Good names. Place them inpersons/[id]/alongside the page test, or in a shared$lib/timeline/testUtils.tsif they're reused byTimelineViewtests in #7. Do not duplicate.i18n key spelling precision.
The key is
person_lebensweg_heading. The value inde.jsonis"Lebensweg"(capital L, German noun). Confirmen.jsongets"Life Journey"andes.jsongets"Camino de Vida"— all three must be added atomically in one commit.Recommendations
const hasLebenswegEntries = $derived(...)before using it in the template.baseData()defaults inpage.svelte.test.tsto includetimeline: { years: [], undated: [] }.persons/[id]/unless #7'sTimelineViewtests also need them — in that case, create$lib/timeline/testUtils.ts.data.timelinefallback uses{ years: [], undated: [] }not[]ornull.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
No new attack surface — the security model here is correct.
The issue correctly identifies this as "read-only re-projection of data already authorized for display on this page." The timeline data is fetched in
+page.server.tsusingcreateApiClient(fetch), which forwards the auth cookie server-side. This is the established, correct pattern — it is not a new API exposure to the browser.The
personIdquery parameter is user-controlled input.GET /api/timeline?personId={person.id}— thepersonIdis the UUID from the already-validated person record (it comes frompersonResult.data!.idafter thepersonResult.response.okguard). This is fine: the UUID is not user-typed input, it is the server's own response. No injection risk here.The graceful-degradation fallback is secure.
The issue mandates: a 403/503 on the timeline sub-fetch must fall back to
{ years: [], undated: [] }, neverresult.data!. This is correct —result.data!after a non-ok response would beundefined, forcing a runtime crash. The?? { years: [], undated: [] }pattern is both safe and correct. Confirm the implementation usestimelineResult.data ?? { years: [], undated: [] }with the!result.response.okcheck, not theresult.errorcheck (which breaks when the spec has no error responses defined).No IDOR risk in this issue.
Permissions are global (
READ_ALL), not per-record. There is no per-person access control on timeline data — if you can see the person page, you can see their timeline. The 403 fallback handles the edge case where the timeline endpoint returns a 403 (e.g., misconfigured role) without exposing a raw backend error message to the user.Cross-issue note for #5's security posture.
The issue documents: "
TimelineService.getForPerson(UUID personId)should callPersonService.getById(personId)first (throwing 404 if absent)." From this issue's perspective, that 404 is harmless because the person page already 404s before the timeline fetch is attempted. However, for direct API callers (someone manually callingGET /api/timeline?personId=<nonexistent-uuid>), silently returning empty instead of 404 is an information disclosure concern — an attacker could enumerate valid UUIDs by checking whether they get an empty or 404 response. The issue already flagged this correctly. Ensure #5 implements thegetByIdpre-check.No new
@RequirePermissionannotation is needed here.The issue is correct: this is a GET that reuses the existing
READ_ALLpermission on the timeline endpoint. No additional authorization work belongs in this issue.Recommendations
timelineResult.data ?? { years: [], undated: [] }(notresult.error) to guard the fallback.page.server.spec.tstest asserting the exact shape{ years: [], undated: [] }when the timeline sub-fetch returnsok: false— the issue already mandates this, but make it explicit that the assertion istoEqual({ years: [], undated: [] }), not justtoBeDefined().🧪 Sara Holt — QA Engineer & Test Strategist
Observations
The test plan is detailed and correct — but has execution traps.
Trap 1: The 8th mock slot is a silent failure risk.
I read
page.server.spec.ts. It has 5 test cases, each using amockResolvedValueOncechain of exactly 7 calls. When you add the 8thPromise.allslot, each existing chain returnsundefinedfor the 8th call. The current+page.server.tsdoes?? []/?? {}fallbacks, so the tests still pass — butdata.timelinewill beundefinedin all 5 existing tests, masking the regression. This is the exact failure mode the issue warns about.The fix is not just adding a chain item to every test — you must verify the test is actually asserting
data.timeline:result.person.firstNameorresult.sentDocumentswill pass even ifdata.timelineis broken.result.timelinedeep-equals the mock value.result.timelineequals{ years: [], undated: [] }exactly (not just that no exception is thrown).Trap 2:
baseData()inpage.svelte.test.tsneedstimelinein defaults.I read the current
page.svelte.test.ts. ThebaseData()factory (line 16-37) does not includetimeline. When the component prop is added, TypeScript will reject tests that usebaseData()withouttimeline. Every existing test breaks at the type level. Fix: addtimeline: { years: [], undated: [] }tobaseData()defaults.Component test for "absent when empty" is non-trivial.
The Lebensweg section must be absent when
data.timeline.years.some(y => y.entries.length > 0) || data.timeline.undated.length > 0is false. The component test must cover three subcases:timeline: { years: [], undated: [] }— fully empty.timeline: { years: [{ year: 1920, entries: [] }], undated: [] }— year band with empty entries.timeline: { years: [], undated: [{ ... }] }— undated entries only (should render the card).The issue spec calls out subcase 2 explicitly as the "fabricated year bands" edge case. Do not skip it.
The
makeTimelineWithEntries()factory needs a defined shape.The issue's
TimelineDTOshape from the spec is{ years: TimelineYearDTO[], undated: TimelineEntryDTO[] }. The factory needs at least oneTimelineYearDTOwithentriescontaining at least oneTimelineEntryDTO. Define a minimalmakeTimelineEntry()factory too, so both the year-band and undated entry tests can stay one-liners.No new Playwright E2E — correct.
The decision to skip new E2E and rely on #7/#11 is sound. The per-person timeline is a composition of existing, independently-tested components. The axe/dark-mode pass on
/persons/[id]correctly belongs to #11.Targeted single-file test runs — respected.
The project rule is to run only
page.server.spec.tsandpage.svelte.test.tslocally, not the full suite. The issue respects this. Flag in the PR: "I rannpx vitest run persons/[id]/page.server.spec.tsandnpx vitest run persons/[id]/page.svelte.test.tslocally."Recommendations
result.timelineassertions to all existing happy-path tests inpage.server.spec.tsto catch silent regressions from the 8th slot.page.svelte.test.ts.makeTimelineEntry()factory alongsidemakeTimeline()andmakeTimelineWithEntries().baseData()factory to includetimeline: { years: [], undated: [] }before any other changes — do this as the first commit to make the failing-type-check visible immediately.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
The placement and visual spec are correct — one gap to close.
Heading hierarchy is correctly specified.
The issue mandates
<h2>for "LEBENSWEG" →<h3>for year bands. I read+page.svelte: the current page usesh2headings insidePersonCard,NameHistoryCard,CoCorrespondentsList,PersonDocumentList(×2), andGeschichtenCard. The Lebensweg<h2>adds to this list. This is consistent — all sibling sections share theh2level. Confirm year bands inside<TimelineView>use<h3>(not another<h2>), since<TimelineView>is being embedded inside an already-<h2>-headed section.The "no nested scroll" constraint is critical for the 60+ audience.
"No
overflow-y-autoinner container" — this is the right call. The global timeline page (/zeitstrahl) may have an inner scroll container for filtering UX reasons; when embedded here, the timeline must extend the page flow. Verify that<TimelineView>does not applyoverflow-y-autoormax-h-*to itself when rendered in embed mode. If<TimelineView>conditionally applies scroll based on context, that is a prop thepersonIdvs. prop-pass mode should communicate. Flag this against #7 ifTimelineViewdoes not have astandaloneprop to disable scroll containment.Typography enforcement: date labels must not use
font-serif.The spec states: date label strings (
ca. 1914,Sommer 1914,Ohne Datum) usefont-sans; person names and event titles usefont-serif. This rule applies both inside<TimelineView>and on any "edit at source" link text. Since #7 ownsEventCard.svelteandLetterCard.svelte, the font assignments are<TimelineView>'s responsibility, not this issue's — but this issue is the first consumer of those components on a real page, so if the typography is wrong, it will be visible here first.Long-name overflow at 375px.
The issue calls out: "When testing at 375px, verify the
LetterCardcomponent truncates withtruncateorline-clamp." This is a manual test that belongs in the PR checklist. Add it explicitly: "Tested at 375px: letter row sender → receiver → snippet truncates without horizontal overflow."Touch targets on "edit at source" links.
"Edit at source" links point to Person/relationship edit screens. These must meet the 44×44px minimum (48px preferred for the 60+ audience). At 375px, inline text links inside a timeline card are at risk of being below the touch target minimum if they are pure text anchors with default padding. Verify
focus-visible:ring-2 focus-visible:ring-brand-navyis applied (already in the issue's task list) and that the link has enough vertical padding to hit 44px.Dark-mode tokens.
The card uses
bg-surface border-line— these are semantic tokens that remap in dark mode. Correct. The section title usestext-ink-3 mb-5— also semantic. The<TimelineView>sub-components must also use semantic tokens, not raw Tailwind colors. This is a #7 concern, but flag it in the PR review of this issue's embed.The
<h2>renders via i18n (m.person_lebensweg_heading()), not hardcoded.This is good — but verify that
svelte-checkdoes not produce a type error for the new i18n key before it exists in the generated paraglide output. The key must be added to all threemessages/*.jsonfiles before the component references it, otherwisenpm run checkwill fail.Recommendations
<TimelineView>appliesoverflow-y-autowithout a way to disable it for embedded mode.<h2>rendering usesm.person_lebensweg_heading()and that the i18n file changes are in the same commit as the component changes.<TimelineView>'s sub-components use semantic color tokens only — flag against #7 if they use raw Tailwind palette colors.⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
Zero infrastructure footprint — no new services, ports, volumes, or env vars.
This issue is a pure frontend composition task riding on
GET /api/timeline?personId=…, which is a new endpoint from #5. My only infrastructure concern is what happens when #5 is not deployed (i.e., this issue lands in staging before #5 lands in production).The graceful-degradation path handles the pre-#5 case correctly.
timelineResult.data ?? { years: [], undated: [] }— whenGET /api/timelinereturns 404 (endpoint doesn't exist yet), the HTTP status is 404,response.okis false, so the fallback kicks in and the Lebensweg card is hidden. This is correct behavior during a phased rollout where #5 has not landed yet.The p95 monitoring note is operationally sound.
The issue flags: "After #5 + #10 land, check
http_server_requests_seconds{uri="/api/timeline", quantile="0.95"}— should stay under 500ms." The Prometheus metric is already instrumented by Spring Boot's Actuator (this is the existing micrometer-based request duration histogram). No new instrumentation needed. Verify on the Grafana dashboard that the/api/timelineURI is correctly captured — Spring Boot's default Micrometer tag may group it as/api/timelinewith thepersonIdquery param stripped (query params are stripped by default in Micrometer's URI tagging). This is correct behavior; the same histogram covers both the global and per-person timeline.The 8th parallel call adds one more outbound HTTP request per person-page load.
The existing 7 parallel calls are already the load profile. Adding an 8th is a minor increase. At current traffic volumes (family project), this is noise. If
GET /api/timeline?personId=turns out to be slow (the spec acknowledges it can be the heaviest read), it is observable via the Prometheus histogram. The issue correctly says to surface this as an indexing problem to #5 rather than papering over it with a client-side lazy-load. That is the right call — a lazy-load would break SSR and auth-cookie forwarding.No new Docker service, no new Compose file changes, no new Caddy config.
Nothing to review on the infrastructure side.
Recommendations
http_server_requests_seconds{uri="/api/timeline", quantile="0.95"} > 0.5with a runbook pointing to #5's index documentation.📋 Elicit — Requirements Engineer
Observations
The acceptance criteria are well-formed and verifiable. All 6 ACs are Given-When-Then testable, with no vague qualifiers. AC2 correctly distinguishes "no dated items" from "year bands with empty entries" — a non-trivial distinction that most requirements miss. AC4 ("375px without horizontal scroll") is measurable. AC6 (graceful degradation) is observable and has an implementation path.
One AC is blocked by another issue and should be marked as such.
AC3 ("Given a marriage between A and B, then the same Heirat event appears on both A's and B's Lebensweg") is explicitly dependent on #5 verifying symmetric assembly. The AC is correct, but it cannot be verified by this issue's implementation alone — it requires #5 to have added the symmetry test first. The issue body notes this, but the AC itself should carry a tag like "(Verifiability depends on #5 AC3 symmetry)" to prevent it from being rubber-stamped as passing when the backend hasn't been tested.
The "hard depends-on" list is clear, but the sequencing signal is buried.
The issue states: "Do not start this issue until #7's spec confirms both signatures are in its acceptance criteria." This is a hard prerequisite, not just a soft dependency. It should be reflected in the issue's blocked-by links, not only in prose. Confirm that the Gitea issue tracker has the blocked-by relationship set.
Missing: a rollback acceptance criterion.
If
GET /api/timelinestarts returning 500 (backend error, not 403/404), the current spec only mandates hiding the Lebensweg card. A 500 on a sub-fetch would causeresponse.okto be false, so the fallback activates. This is the correct behavior. However, the ACs do not cover the 500 case explicitly — only 403/503 are named in the Security section. Consider adding: "Given the timeline endpoint returns a 5xx error, the Lebensweg card is hidden and the rest of the person page renders normally." This is a superset of AC6 but makes the test coverage intent explicit.The i18n requirement (AC5) is verifiable but needs a concrete test.
AC5 says "Heading uses i18n key
person_lebensweg_headingin de/en/es." The component test assertsgetByRole('heading', { name: /lebensweg/i })— this matches the German (default) translation. The test does not verify English or Spanish. For a feature this explicitly i18n-specified, add a test that switches locale and verifies the heading changes. This is low-cost in Paraglide: setdocument.documentElement.langor use the Paraglide test utility to switch the active language.Data scope is clear — one implicit assumption worth surfacing.
The scope states letters they "sent or received" are included. The
+page.server.tsalready fetchessentDocumentsandreceivedDocumentsseparately. The timeline assembly endpoint (#5) does its own query for the same data. This means the person page will fetch some letter data twice: once forPersonDocumentListand once for the timeline. This is not a bug (they serve different presentation purposes), but it is worth noting in the issue as an explicit, accepted trade-off so it is not "fixed" by someone trying to DRY up the calls.Recommendations
!response.okfor 5xx, matching the existing pattern.)Visual spec for the per-person Lebensweg (on
main, commitddb1ec4d):docs/specs/zeitstrahl-final-spec.html— §4 shows the reuse: the sameTimelineViewwith apersonIdprop placed in the 35 % left rail underPersonCardon the Person detail page (persons/[id]/+page.svelteislg:grid-cols-[35%_65%]), filtered to one person (own Geburt/Heirat/Tod, involved world-events as context, own letter clusters). §5 covers the responsive story — same component, narrow rail = the phone left-axis layout (a real argument for Concept A: one idiom from rail → phone → desktop).