Timeline: filters (person / generation / layer / year range) #780
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"Depends on: #5 (assembly endpoint
GET /api/timeline+ its query params), #7 (global/zeitstrahlview +TimelineView.svelte).Overview
A presentation-only filter bar,
TimelineFilters.svelte, that drives theGET /api/timelinequery params for the global/zeitstrahlview. This issue is frontend-only — it stays strictly within the query-param contract already owned by #5 and adds zero backend code, no new endpoint, no migration, no infra, no env var. The canonical pattern to mirror isfrontend/src/routes/SearchFilterBar.svelte.Scope
TimelineFilters.svelte— a dumb/presentation component:$bindablefilter props +onSearch/onSearchImmediatecallbacks, nogotoinside it./zeitstrahl/+page.sveltebuilds the query string and callsgoto;/zeitstrahl/+page.server.tsreadsurl.searchParams, coerces/validates, and forwards toGET /api/timeline.parseTimelineFilters(searchParams)atsrc/routes/zeitstrahl/parseTimelineFilters.ts— route-scoped coercion, not a reusable library utility.File boundary with #7: #7 scaffolds the
/zeitstrahlroute (creates+page.svelte,+page.server.tsstubs). This issue extends those files with filter wiring; it does not create them from scratch.CLAUDE.mdroute table anddocs/architecture/c4/l3-frontend-*.pumlupdates are scoped to #7, not this issue.Out of scope: any backend/contract change, any new
Personfield, generation-matching semantics (owned byTimelineServicein #5).Filter dimensions (final)
personId(UUID string). ReusePersonTypeahead(project standard, as inSearchFilterBar).generation(integer 0–10, G0 = oldest, bounds perPersonGeneration). Labeled<select>dropdown (bounded set; a dropdown beats free text). No "family branch" — see Decisions Resolved D1.<fieldset><legend>Ebenen anzeigen</legend>...</fieldset>for screen-reader group context:type=PERSONAL; fires viaonSearchImmediate(instant, no blur needed).type=HISTORICAL; fires viaonSearchImmediate.onSearchImmediate.fromYear/toYear(integers), inclusive on both ends. Twotype="number"inputmode="numeric"inputs. FireonSearchonblur(notoninput) — see Decisions Resolved D4.typeparam mapping for the personal/historical togglestype(server returns both).type=PERSONAL.type=HISTORICAL.GET /api/timelinenormally (type omitted); hide all returned event cards client-side — see Decisions Resolved D5. A code comment on the mapping function must explain this: "Both toggles off: API is still called normally; event cards are hidden client-side. Consistent with the D3 letters pattern. Known limitation: counts include hidden items."Locked-person mode (Lebensweg / #10)
TimelineView.svelteaccepts an optionalpersonIdprop. On the per-person Lebensweg page (#10) the person is fixed by the prop.TimelineFiltersmust support alockedPersonIdmode where the person field is hidden entirely (not disabled-but-present — a disabled field confuses screen-reader users).lockedPersonIdis a plain (non-$bindable) typed prop — it comes from the parent as a fixed value and cannot be changed within the filter bar.Data flow / architecture
+page.server.tsload → props. Never a client-sidefetch('/api/timeline')inonMount(would expose the API route and bypass cookie-forwarded auth).+page.sveltebuilds the query string and callsgoto(\?${params}`, { keepFocus: true, noScroll: true })`. Empty/absent params are omitted, never sent as empty strings.+page.server.tsreadsurl.searchParams, runsparseTimelineFilters, and callsapi.GET('/api/timeline', { params: { query: {...} } }).GET /api/timelinefresh — fine for family-scale traffic, no CDN/cache config).onSearchImmediatecallback pattern (mirrorSearchFilterBar's AND/OR operator toggle). Year inputs fire ononblur.Input coercion & validation (
parseTimelineFilters)File:
src/routes/zeitstrahl/parseTimelineFilters.tsExport the
TimelineFilterstype from this file for use in+page.svelteprop types:Defense-in-depth in the load function, even though #5 also validates:
personId: pass through as UUID string if present.generation:Number.parseInt; rejectNaN; validate to0..10(mirrorPersonGeneration); drop if out of range (do not clamp to nearest valid). Ifgeneration=99is in the URL, parsed output hasgeneration: undefined→ UI shows "no generation filter active."fromYear/toYear:Number.parseInt; rejectNaN; clamp to sane window (1000 .. new Date().getFullYear() + 1— computed at call time, not a hardcoded constant); iffrom > to, swap (never forward an inverted range).generation=abcorfromYear=99999999999must never cause a 500 or unbounded scan./api/timelineerror viagetErrorMessage(code)— never render the raw backend message.READ_ALLuser may read any person's timeline by design (family-wide read). Do not add a per-person guard.UX & Accessibility
slide-transitioned collapsible, closed by default ≤768px, with a sticky/persistent "Filter (N active)" trigger so the active-filter count is always visible. Respectprefers-reduced-motion: useduration: 0on theslidetransition whenwindow.matchMedia('(prefers-reduced-motion: reduce)').matches—svelte/transition'sslidedoes not honor this automatically.$derivedcount of active filters. A filter is active when it deviates from the default state: person set, generation set (including G0), year range set, or a layer toggle turned off. Default = both layers on, no person, no generation, no year range = 0 active. Touch target:min-h-[44px].SearchFilterBar's undated-toggle markup —aria-pressed,min-h-[44px], a visible ✓ glyph inside, brand tokens (bg-primary text-primary-fgactive /bg-muted text-ink-2inactive). Each toggle gets a realaria-label(e.g. "Historische Ereignisse anzeigen"). Never signal active state with color alone (WCAG 1.4.1). Wrap all three in<fieldset><legend>Ebenen anzeigen</legend>...</fieldset>.<select>(G0 oldest → G10) with a real<label for>,min-h-[44px]on the wrapper div (the<select>itself fills the wrapper).type="number"inputmode="numeric"inputs with visible<label>s ("von Jahr" / "bis Jahr"), 16px+ text, side-by-side at 375px (min-w-0 flex-1each). Inline icon+text validation iffrom > to(not color-only). FiresonSearchonblur.PersonTypeahead; hidden entirely in locked-person mode (not disabled — not present in the DOM)./zeitstrahl. A$derived"any filter active" flag drives its visibility (no$effect).Behavior of special states
GET /api/timelineis still called normally; all returned event cards are hidden client-side. A code comment on the hiding logic must read: "Both event toggles off: API called normally, cards hidden client-side. Consistent with D3 letters pattern." Only letters (if their toggle is on) remain visible.includeLettersparam on GET /api/timeline is deferred as a later refinement."Decisions Resolved
branch/Zweigfield exists onPerson(onlygenerationInteger 0–10, CHECK in V70). "branch/generation" was ambiguous; introducing a branch dimension is a separate foundational issue (new column + Flyway migration + importer change + DB diagrams) and out of scope for a frontend-filters issue. Rationale: keeps the issue frontend-only and avoids a silent undefined data dimension.personIdand year range. Known limitation: year/generation counts include hidden letters. A server-sideincludeLettersparam onGET /api/timelineis deferred as a later refinement.onSearchononblur, notoninput. Rationale: typing "1930" fires fouroninputevents;onblurfires once. The senior-primary audience types slowly; debounce is invisible to slow typists. An explicit apply button would be clearest but adds UI weight not justified for two inputs.onbluris the right tradeoff.i18n Keys (required in
messages/{de,en,es}.json)The implementer must add these keys following the existing naming convention. German values shown as reference:
timeline_filter_label_persontimeline_filter_label_generationtimeline_filter_generation_option_alltimeline_filter_label_layerstimeline_filter_layer_personaltimeline_filter_layer_historicaltimeline_filter_layer_letterstimeline_filter_label_from_yeartimeline_filter_label_to_yeartimeline_filter_triggertimeline_filter_trigger_activetimeline_filter_resettimeline_filter_empty_statetimeline_filter_year_range_errorTasks
src/routes/zeitstrahl/parseTimelineFilters.ts— exportTimelineFilterstype andparseTimelineFilters(searchParams)function. Route-scoped coercion; not insrc/lib/.TimelineFilters.svelte— presentation component:$bindablefilter props +onSearch/onSearchImmediatecallbacks, nogoto. SupportslockedPersonIdplain prop (person field absent from DOM, not disabled).PersonTypeahead(hidden entirely whenlockedPersonIdset); generation<select>(0–10,min-h-[44px]wrapper); three layer toggles inside<fieldset><legend>Ebenen anzeigen</legend>using thearia-pressed+ ✓-glyph +min-h-[44px]pattern; year-range pair (type="number"inputmode="numeric"inputs with visible labels,onblurtrigger).slidetransition withprefers-reduced-motionguard (duration: 0when motion is reduced).$derivedactive-filter count; G0 counts as active; default state (both layers on, no person, no generation, no year range) = 0 active./zeitstrahl/+page.svelte(extend from #7 stub): owns URL state, builds query string, callsgoto(?…, { keepFocus, noScroll }), omits absent params./zeitstrahl/+page.server.ts(extend from #7 stub): readsurl.searchParams, validates viaparseTimelineFilters, forwards toapi.GET('/api/timeline'); maps errors viagetErrorMessage.from > toswap/validation (inline icon+text, not color-only); clamp years to1000..new Date().getFullYear() + 1.$derived"any filter active" flag (no$effect)./zeitstrahl.messages/{de,en,es}.json(see table above).Acceptance criteria
generation=99in the URL, parsed output has no generation filter and the UI shows "no generation filter active."fromYear=1920&toYear=1930, when applied, then year bands outside 1920–1930 are hidden and the undated bucket is hidden (D2). Given the year range is then cleared, the undated bucket is visible again.type; the letters toggle hides letter cards client-side; toggling reflectsaria-pressedand does not rely on color alone. Given both event toggles are off, the timeline shows no event cards and the letters layer (if toggle is on) is shown; the API is still called normally./zeitstrahl.generation=abc, out-of-rangegeneration=99, orfromYear=notanumberare dropped/coerced cleanly — never a 500 or crash.+page.server.ts, never via client-sidefetchinonMount./zeitstrahlwith filters open, in light and dark mode, at 375px (checked via existingaccessibility.spec.ts, not a standalone spec).Tests
parseTimelineFilters:NaN/out-of-range generation and years.generation=99→generation: undefinedin output (drop, not clamp).{ personalOn: false, historicalOn: false }→typeFilterisundefined(type omitted) — load function still calls API; event hiding is client-side. Test must assert the exact value passed toapi.GET.TimelineFilters.svelte.spec.ts, browser/client project, single-file local runs):aria-pressed.onSearchonblur, not oninput.lockedPersonIdmode →queryByRole('combobox', { name: /Person/ })returnsnull(not in DOM).createApiClientat module boundary — followdocuments/page.server.spec.tspattern):{generation},{fromYear+toYear},{type},{personId}, plus 2–3 combinations → assert exact query object passed toapi.GET('/api/timeline', …).generation=99,fromYear=abc, inverted year range swapped.generation=0→ passed as0(G0 is valid, not dropped).accessibility.spec.tsfor axe; one journey file for filter behavior):/zeitstrahl, apply person + year-range, reload, assert URL params persist and the filtered set renders.accessibility.spec.ts, reusing existing dark-mode toggle infrastructure — not a standalone file).test.skipguard on E2E spec until #7 is merged (the/zeitstrahlroute does not exist until #7 lands; CI branch ordering enforces the dependency).🏗️ Markus Keller — Senior Application Architect
Observations
File placement is correct.
parseTimelineFilters.tsat route scope (src/routes/zeitstrahl/) — notsrc/lib/— is exactly the right call. Route-scoped coercion must not become shared infrastructure.SSR-first constraint is explicit and enforced by design. No client-side fetch in
onMount, load function owns the data flow. This matches the project's established pattern (seen in/documents/+page.svelteand/persons/+page.svelte).Architecture doc update scope is correctly excluded. The issue notes that
CLAUDE.mdroute table +docs/architecture/c4/l3-frontend-*.pumlupdates are scoped to #7 (the route scaffold), not this issue. That boundary is clean.Letters client-side filter is an acknowledged deferral, not an architectural mistake. D3 is well-reasoned: the filter bar stays frontend-only, #5's contract is not reopened. The required code comment on the letters toggle is the right compensating control.
lockedPersonIdprop design is architecturally sound. A non-$bindabletyped prop (fixed at the parent level, not mutable inside the component) is the correct primitive for a locked read-only value. DOM removal (not present) vs.disabledis the right choice — see below.Recommendations
The
parseTimelineFiltersfunction has one implicit architectural risk: the issue specifies thatfromYear > toYearis swapped silently. This is correct for robustness, but the function must return the swapped values back to the page so that the URL can be corrected via a replace-stategoto. If the load function swaps internally but the URL still reflects the inverted order, a page reload will re-swap. The issue says "iffrom > to, swap (never forward an inverted range)" — the implementer must also update the URL to reflect the canonical form, or the reload behavior is inconsistent with AC "state survives reload via URL."TimelineFiltersis a correct scope boundary. No business logic, nogoto. The pattern mirrorsSearchFilterBar.svelte, which correctly puts all URL management in+page.svelte. Enforce this at review time — anygotoorurl.searchParamsaccess insideTimelineFilters.svelteis a boundary violation.C4 diagram update note: Since this issue extends the
/zeitstrahlroute (not creates it), no new diagram entries are needed. However, verify the PR doesn't add new files tosrc/lib/that would requirel3-frontend-*.pumlupdates.TimelineFilters.sveltemust live insrc/routes/zeitstrahl/, notsrc/lib/.Open Decisions
fromYear > toYearis swapped inparseTimelineFilters, should the load function also trigger aredirectto the corrected URL (so the URL bar reflects the canonicalfrom ≤ toorder), or is silent swap in the query call sufficient? Silent swap is simpler and the issue implies it. But a reload with the original swapped URL re-runs the swap, so the behavior is technically consistent — just confusing if a user shares a link withfromYear=1950&toYear=1920. This is a product judgment, not a technical one.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
The issue is well-specified and aligns tightly with existing patterns. I reviewed
SearchFilterBar.svelte(the canonical reference) anddocuments/page.server.spec.ts(the test pattern to mirror). Several implementation details warrant attention:The
slidetransition inSearchFilterBar.svelteuses baretransition:slidewith noprefers-reduced-motionguard. This issue correctly calls out the gap —duration: 0when motion is reduced. The existing filter bar has this unresolved;TimelineFiltersmust not repeat the pattern. Implementation: checkwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesbefore assigning transition params. I saw this guard used in+page.svelteforStammbaumTree.sveltealready — same approach.The
$derivedactive-filter count needs a precise definition. The issue says: "A filter is active when it deviates from the default state: person set, generation set (including G0), year range set, or a layer toggle turned off." G0 counting as active is a subtle but important correctness requirement —generation === 0must be treated identically togeneration === 5. A naïveif (generation)check would wrongly treat G0 as inactive (falsy zero).The
$effectprohibition is explicit: active-filter count must be$derived, not$state+$effect. The existingSearchFilterBar.sveltehas one$effectfor sort-direction tracking (lines 74–84 there), but it's a known workaround, not a pattern to copy. The timeline filter count is a straightforward$derived— no workaround needed.lockedPersonIdDOM removal test:queryByRole('combobox', { name: /Person/ }) === nullis a stronger assertion thannot.toBeVisible(). The issue is explicit about this — DOM removal, not CSS hide. Make sure{#if !lockedPersonId}wraps the entirePersonTypeaheadblock, not just a visibility class.$props()runes pattern — theTimelineFilterscomponent should declare props using the Svelte 5$props()rune with explicit TypeScript types (matching the pattern inSearchFilterBar.svelte, lines 11–60). The$bindabledecorator is correct for mutable filter state;lockedPersonIdis a plain typed prop (no$bindable).Missing test case: The issue's test matrix mentions
{ personalOn: false, historicalOn: false }→typeFilterisundefined. The test must assert the exact value passed toapi.GET, not just the component behavior. Mirror thedocuments/page.server.spec.tspattern: mockcreateApiClient, callload()with the URL params, assertmockGetwas called with the exact query object.{#each}key discipline: Any loop over timeline entries in the parentTimelineView.sveltemust use keyed iteration{#each entries as entry (entry.id)}. This issue doesn't touch that component directly, but if the filter state causes list updates, unkeyed loops will corrupt DOM state.Recommendations
const activeFilterCount = $derived(...)with explicit conditions for each dimension. Extract a helper functionisDefaultState(filters: TimelineFilters): boolean— testable in isolation, readable at the call site.onSearch/onSearchImmediatecallback pattern is already proven inSearchFilterBar. Copy the exact naming and invocation pattern (see lines 196–200 ofSearchFilterBar.sveltefor the AND/OR toggle as the reference for layer toggles firingonSearchImmediate).parseTimelineFiltersunit tests: use themakeUrl()factory pattern fromdocuments/page.server.spec.ts(lines 13–25) — identical helper needed here.von Jahrmuss ≤bis Jahrsein") must use an icon + text, not red border alone. Reference the<span class="text-red-600 flex items-center gap-1"><svg>...</svg>...</span>pattern from Leonie's guidance.🔒 Nora "NullX" Steiner — Application Security Engineer
Observations
This is a read-only filter bar with no write operations and no data mutation. The attack surface is narrow: URL parameter injection and client-side DOM manipulation. The issue is unusually thorough on input validation — the IDOR note, the defense-in-depth framing for
parseTimelineFilters, and the "drop invalid params silently" policy are all correctly specified.IDOR non-issue is correctly assessed. The issue explicitly states: "IDOR is a non-issue: any
READ_ALLuser may read any person's timeline by design." This is consistent with the existing family-archive threat model (no per-user data segregation). No per-person guard needed.Malformed URL param injection is addressed by
parseTimelineFiltersdesign. Drop invalidgeneration, NaN years, out-of-range values — these are the right controls. The key security property is that no raw user input from the URL reachesapi.GETunvalidated. TheparseTimelineFiltersfunction is the sanitization boundary.One gap:
personIdvalidation. The issue says "pass through as UUID string if present" but specifies no UUID format validation. A malformedpersonId=../../../../etc/passwdpassed toapi.GET('/api/timeline', { params: { query: { personId: ... } } })is not a path traversal risk (it's a query param, not a path param), but the backend will receive garbage. Recommendation: validatepersonIdagainst a UUID regex before passing through. Thedocuments/page.server.spec.tspattern does UUID validation before person name lookup (line 382: "returns empty string when senderId is not a valid UUID") — mirror that guard.SSR-first protects auth. The issue correctly states: "Never a client-side
fetch('/api/timeline')inonMount(would expose the API route and bypass cookie-forwarded auth)." This is the critical security property. The auth cookie is forwarded by the SvelteKithandleFetchhook — client-side fetch bypasses it entirely. No additional control needed beyond enforcing the SSR-first constraint, which is already in the AC.Year clamping boundary.
new Date().getFullYear() + 1is computed at call time (correct — not a hardcoded constant). A hardcoded year like2026would silently stop accepting valid years after that. The call-time computation is the right approach.Error mapping. The issue requires
getErrorMessage(code)— no raw backend messages to users. This is correct and consistent with the codebase pattern.Recommendations
personIdinparseTimelineFilters. A simple UUID regex check (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) before passing through to the API. If the format is invalid, drop it (personId: undefined). Add a unit test:personId=not-a-uuid→ parsed output haspersonId: undefined.api.GET. The load-function tests should assert that whenpersonId=garbageis in the URL,api.GETis called withpersonIdabsent from the query object — not with the garbage string.No open security decisions — the analysis is complete.
📋 Elicit — Requirements Engineer
Observations
The spec is unusually dense and well-formed for a frontend-only filter issue. Most requirements are testable, the decisions are explicitly resolved, and the i18n table is complete. This round focuses on what is still underspecified or potentially contradictory given the spec's current richness.
AC gap — year-range swap behavior on reload. The AC says "a filtered URL reloaded re-activates the same filters and renders the same entries." But the spec also says
fromYear > toYearis swapped silently inparseTimelineFilters. If a user shares?fromYear=1950&toYear=1920, the load function swaps to1920–1950for the API call, but the URL still shows1950–1920. On reload, the same swap happens — behavior is consistent, but the filter bar will show the corrected values (von: 1920,bis: 1950) while the URL shows the original inverted values. This is a requirements gap: the AC says "state survives reload via URL" but doesn't specify whether the URL is also corrected. Worth clarifying explicitly.Missing i18n key for year-range error. The table has
timeline_filter_year_range_error("„von Jahr" muss kleiner oder gleich „bis Jahr" sein.") — this is the inline validation message. But the spec doesn't specify when this message is shown vs. hidden. Presumably: shown whenfromYear > toYear(before the swap happens client-side, during typing before blur), hidden once the values are valid. This behavior should be in the AC or tasks to avoid ambiguity in implementation."Filter (N active)" trigger localization. The i18n table shows
timeline_filter_trigger("Filter") andtimeline_filter_trigger_active("Filter ({count} aktiv)"). Two separate keys for 0 vs. ≥1 active filters is the correct pattern (avoids awkward "Filter (0 aktiv)"). The{count}placeholder in Paraglide — verify this uses the project's existing pluralization helper or them.timeline_filter_trigger_active({ count: n })call signature matches how other count-carrying messages are defined inde.json."Reset" AC edge case. The AC says reset "returns to the unfiltered
/zeitstrahl." But what does "unfiltered" mean for the URL? Is itgoto('/zeitstrahl')(bare, no params) orgoto('/zeitstrahl?')(empty params)? The existingSearchFilterBar.svelteuses<a href="/documents">(bare path). Recommend specifyinggoto('/zeitstrahl')explicitly in the task."Both toggles off" with letters toggle also off. The spec says: "Given both event toggles are off, the timeline shows no event cards and the letters layer (if toggle is on) is shown." This covers the case where letters toggle is ON. But what if all three toggles are off? The spec says letters are client-side hidden when their toggle is off — so all three off → zero visible items → empty state should render. The AC should include: "Given all three layer toggles are off, the empty-state message is shown." Currently absent.
Undated bucket visibility in locked-person mode. The spec addresses undated bucket behavior with year range (hidden) and without (visible), but doesn't address what happens in locked-person mode (
lockedPersonIdset). Presumably the same rules apply — but worth confirming since the Lebensweg view (#10) may have different expectations for the undated bucket.Recommendations
fromYear > toYear, when the page is reloaded with those URL params, the filter bar shows the corrected (swapped) year values, and the timeline displays entries for the canonical (correct) year range."goto('/zeitstrahl')(bare path, no query string).fromYear > toYearand both inputs have been touched; hidden when values are valid or when only one input has a value.Open Decisions
fromYear > toYearis in the URL, should+page.server.tsemit aredirect(301, corrected_url)to fix the URL, or is silent swap (correct API call, wrong URL) acceptable? Redirect is more correct but adds a round-trip. Silent swap is simpler and the issue implies it. The current spec says "swap" — confirm this means silent swap only, not URL correction.🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
The spec is exceptionally detailed on accessibility requirements — more so than most issues in this codebase. The dual-audience concern (phone-first readers + seniors on desktop) is explicitly addressed. I reviewed
SearchFilterBar.svelteas the canonical reference and have specific observations:slidetransition inSearchFilterBar.sveltehas no reduced-motion guard (line 173). The issue correctly mandatesduration: 0whenprefers-reduced-motion: reduceis active. Important: this must be a reactive value (readingwindow.matchMediaat mount time), not a hardcoded constant, because media preference can change while the page is open. The implementation should be:Note: I found this exact
window.matchMediapattern already in/documents/[id]/+page.svelte(line 58 approximately) — copy from there.Year-range inputs side-by-side at 375px. The spec mandates
min-w-0 flex-1each.min-w-0is essential — without it, a flex child won't shrink below its content width, causing overflow on 375px screens. The numbers "1920" and "1930" fit fine, but typed partial values ("19") may show 4-char placeholder text. Verify the placeholder text for "von Jahr" and "bis Jahr" doesn't overflow its flex container at 375px.min-h-[44px]on the filter trigger. The spec requires this. Also applymin-h-[44px]to all three layer toggle buttons and the generation<select>wrapper — not just the collapsible trigger. All four interactive elements are in the senior user's critical path.Generation
<select>touch target. The spec saysmin-h-[44px]on the wrapper div. Native<select>elements on iOS have inconsistent touch target sizes. The wrapperdivapproach (wrapping<select>to fill it) is correct — but verify the<select>itself usesclass="w-full h-full"inside the wrapper. Without this, the wrapper is 44px tall but the select element inside may be smaller, creating a dead zone.✓ glyph in layer toggles. The spec mandates a visible ✓ glyph inside active toggle buttons (mirroring the
SearchFilterBar's undated toggle at lines 295–301). This must be visible to both sighted and non-sighted users:aria-pressedcovers screen readers; the ✓ glyph covers sighted users. Never rely on color alone (WCAG 1.4.1). The existing undated toggle pattern is the exact template to copy."Filter (N active)" trigger — seniors miss disappearing UI. The spec says the trigger is "sticky/persistent" — visible even when the collapsible is closed. Ensure the trigger stays in the document flow (not
position: stickywith a fixed offset that clips it on small screens). At 375px in landscape, if the sticky trigger overlaps the timeline content, that's a usability regression.Reset button text vs. icon. The spec mandates a text button, not icon-only. The existing
SearchFilterBaruses an icon-only reset (<a href="/documents">with just a close icon). The timeline filter must do better: a text button labeled "Filter zurücksetzen" is required. This is an explicit accessibility call-out — seniors miss icon-only affordances.Dark mode for the filter collapsible. All color classes must use semantic tokens (
bg-surface,border-line,text-ink-2) — no raw Tailwind palette colors. The layer toggles usebg-primary text-primary-fg(active) andbg-muted text-ink-2(inactive) — confirm these tokens exist inlayout.cssand have dark-mode remappings. Thebg-primary/text-primary-fgtokens are used inSearchFilterBar(line 196), so they exist.Empty state with filters open. When filters are open and zero results are returned, the empty-state message "Keine Einträge entsprechen diesen Filtern." + reset button should appear below the open filter bar, not replacing it. Users need to see their active filters to understand why nothing matched. The reset button in the empty state and the reset button in the filter bar must both work consistently.
Recommendations
window.matchMedia('(prefers-reduced-motion: reduce)')guard from/documents/[id]/+page.svelteexactly.text-smortext-xsfor labels if needed to prevent wrapping.<select>should use a<label for="generation">(notaria-label) — native label association works better across assistive technologies than ARIA workarounds.No open UX decisions — the spec is specific enough to implement without further input.
🧪 Sara Holt — QA Engineer & Test Strategist
Observations
The test plan in the issue is the most complete I've seen in this codebase. The three-layer structure (unit/component/E2E) maps directly to the project's test pyramid. I validated the file patterns against existing tests before commenting.
documents/page.server.spec.tsis the correct pattern to mirror — themakeUrl()factory,vi.mock('$lib/shared/api.server'),beforeEach(vi.clearAllMocks), andmockGet.mock.calls[0][1].params.queryassertion style are all established conventions. The load-function tests for this issue should look identical structurally.Missing test case for
generation=0(G0 valid, not dropped). The issue mentions this in the task list: "generation=0→ passed as0(G0 is valid, not dropped)." Confirming this must be a unit test inparseTimelineFilters, not just implied by the component behavior. A naïve implementation usingparseInt+ truthiness check would drop G0. The test must assert:parseTimelineFilters(makeUrl({ generation: '0' })).generation === 0.Missing test case for
personIdUUID validation. As flagged by Nora: ifpersonIdgets UUID validation inparseTimelineFilters(recommended), there must be a unit test:personId=not-a-uuid→personId: undefinedin output.lockedPersonIdDOM absence test is correctly specified.queryByRole('combobox', { name: /Person/ }) === nullis the right assertion. The component test must not usetoBeVisible()— that only checks CSS visibility, not DOM presence.queryByRolereturningnullproves the element is not in the DOM.The
test.skipguard on E2E spec is essential. Since/zeitstrahldoesn't exist until #7 merges, any E2E test withouttest.skipwill fail in CI. The guard must be:or conditionally skip based on an env var. The implementation note says "CI branch ordering enforces the dependency" — clarify this in a code comment on the skip, otherwise future readers won't know when to remove it.
axe check in
accessibility.spec.ts— additive, not a new file. The issue correctly scopes this: add toAUTHENTICATED_PAGESarray inaccessibility.spec.tsonce #7 lands. The existingbuildAxe()helper and dark-mode test pattern in that file are already the right infrastructure. No new spec file needed.Component test file naming. The issue specifies
TimelineFilters.svelte.spec.ts— this is correct for theclientVitest project (browser-based). Confirm the Vitest config maps*.svelte.spec.tsto the browser project. I checked:PersonTypeahead.svelte.spec.tsexists atsrc/lib/person/, confirming the naming convention is established.Load-function test isolation gap. The issue specifies mocking
createApiClientat module boundary. One edge case not in the matrix: what happens whenapi.GET('/api/timeline')throws (network error)? The existingdocuments/page.server.spec.tscovers this (lines 316–329) — mirror that pattern. The load function should return{ error: ... }not throw."Combinations work" matrix. The issue says "2–3 combinations." Minimum recommended combinations to cover:
{ generation: 2, fromYear: 1920, toYear: 1940 }— year + generation together{ personId: UUID, type: 'PERSONAL' }— person + type filter{ fromYear: 1900 }— single bound (notoYear) — ensuretoYearis omitted, not sent asundefinedstringCI time impact. Unit tests for
parseTimelineFilterswill be fast (<10s). Browser component tests add ~3s per run (single-file local). E2E (skipped until #7) adds 0s to the current CI budget. Load impact is minimal.Recommendations
generation=0→ passed as0(not dropped).personId=not-a-uuid→personId: undefined(if UUID validation is added per Nora's recommendation).fromYearonly, notoYear) ���toYearabsent fromapi.GETcall, not sent asundefined.typeparam, event cards hidden client-side (component-level).test.skipremoval condition in a code comment: "Remove skip when #7 (/zeitstrahlroute) is merged."No open test strategy decisions.
🖥️ Tobias Wendt — DevOps & Platform Engineer
Observations
This is a frontend-only issue with no new infrastructure, no new Docker services, no new env vars, and no new backend endpoints. From an ops perspective, the scope is clean.
No CI impact on the current pipeline. E2E tests are explicitly
test.skipped until #7 lands — confirmed by the issue. The unit and component tests forparseTimelineFiltersandTimelineFilters.sveltewill run in the existing Vitest pipeline. No pipeline changes needed.No new env vars. The issue explicitly states this. The year clamping uses
new Date().getFullYear() + 1computed at call time — no env var needed. Correct.No new Docker service or infrastructure component. The filter bar calls
GET /api/timeline(owned by #5). If #5 isn't deployed, the filter bar returns empty — it doesn't crash CI.test.skipCI ordering concern. The E2E filter behavior test is skipped until #7 merges. But the issue says "CI branch ordering enforces the dependency." This needs to be more concrete: if #8 (this issue) merges before #7, the CI formainwill have an E2E test file that is skipped but present. That's acceptable — skipped tests don't fail CI. What's not acceptable is if thetest.skipis removed without #7 in place. Recommend the skip comment explicitly state the dependency:// Remove when issue #7 merges: /zeitstrahl route does not exist until then.No observability changes needed. The filter bar is presentation-only. The
GET /api/timelinecalls will appear in the existing Loki/Grafana log pipeline with no additional configuration.Pre-commit hook note. The issue adds new files to
frontend/src/routes/zeitstrahl/and new i18n keys tomessages/{de,en,es}.json. The pre-commit hook runscd frontend && npm run lint. A fresh worktree (per project memory:feedback_precommit_needs_frontend_install.md) needsnpm installbefore the first commit. If this is implemented in a worktree, runnpm installbefore committing.Recommendations
.enventries are introduced.test.skipcomment as a code note.frontend/messages/en.jsonandmessages/es.jsonget the i18n keys added alongsidede.json— the pre-commit hook runs lint but not paraglide compilation; a missing key in one language file is a silent runtime regression.No open DevOps decisions — this issue is correctly scoped as frontend-only.