feat(timeline): layer filter (Personal / Historical / Letters) for /zeitstrahl #843

Merged
marcel merged 8 commits from feat/issue-780-timeline-layer-filter into main 2026-06-14 22:11:39 +02:00
Owner

Closes #780.

A presentation-only, fully client-side layer filter for the global /zeitstrahl: a TimelineFilters bar with three toggles (Personal / Historical / Letters) that derives a $derived filtered view of the already-loaded timeline. No backend, endpoint, migration, URL state, API call, or generate:api — the SSR load (owned by #779) is untouched. Reloading returns to the default all-layers-on view.

Built TDD red→green; the Svelte MCP autofixer validated both .svelte files.

What's in it

  • timelineFilter.ts — pure isDefaultState / hiddenLayerCount / filterTimeline (drops year bands left empty, filters the undated bucket; input never mutated).
  • TimelineFilters.svelte — dumb bar: three $bindable toggles in a <fieldset><legend>, sticky "Filter (N aktiv)" trigger, reset text button (visible only when a layer is off), reduced-motion-aware slide (duration 0 when prefers-reduced-motion, reusing the guard from documents/[id]/+page.svelte:57). Toggles mirror the SearchFilterBar undated-toggle markup (aria-pressed, ✓ glyph, 44px target, semantic tokens).
  • /zeitstrahl/+page.svelte — holds the three toggles in $state, binds them into the bar, derives the filtered TimelineDTOTimelineView; renders a filter-specific empty-state + one-click reset below the open bar when nothing is visible (never blank, never the generic "no events" state). Meta-line keeps counting the unfiltered timeline (D1 known limitation, commented).
  • i18n — 8 timeline_filter_* keys in de/en/es (trigger vs trigger_active({count}) distinct).
  • Guards & tests — a static boundary spec fails the build if either file ever reintroduces goto/url.searchParams/api.GET/fetch; an un-skipped Playwright journey + 375px axe (light + dark).

Requirement coverage

All REQ-001REQ-010 implemented, each with a green test and a Done row in .specify/rtm.md. Full REQ → commit → test mapping is in the completion comment on #780.

Verification

  • 44/44 unit + component tests green (timelineFilter, messages, timelineFilterBoundary, TimelineFilters.svelte, zeitstrahl/page.svelte — the 7 pre-existing page tests stay green).
  • svelte-check: zero errors in any touched file (repo's ~800 pre-existing baseline is unrelated/ungated).
  • prettier/eslint clean — pre-commit gate passed on all 7 commits.

Reviewer notes

  • REQ-009 (reduced-motion) is verified by code review + autofixer, per the spec's own "component / manual" test-matrix designation — only the slide duration is zeroed; the matchMedia guard is reused verbatim.
  • E2E (zeitstrahl-filter.spec.ts) is authored un-skipped and Playwright-collection-verified, but not executed in this environment (local stack unhealthy: backend health 500, no frontend on :3000). Run with the full stack up: npx playwright test zeitstrahl-filter.spec.ts. E2E is not wired into CI per e2e/CLAUDE.md.
  • The spec carried a "re-run /review-issue" banner from its 2026-06-14 rescope; implementation proceeded by explicit agreement after verifying the 10 REQs are testable and #779 is merged.

🤖 Generated with Claude Code

Closes #780. A presentation-only, **fully client-side** layer filter for the global `/zeitstrahl`: a `TimelineFilters` bar with three toggles (Personal / Historical / Letters) that derives a `$derived` filtered view of the already-loaded timeline. No backend, endpoint, migration, URL state, API call, or `generate:api` — the SSR load (owned by #779) is untouched. Reloading returns to the default all-layers-on view. Built TDD red→green; the Svelte MCP autofixer validated both `.svelte` files. ### What's in it - **`timelineFilter.ts`** — pure `isDefaultState` / `hiddenLayerCount` / `filterTimeline` (drops year bands left empty, filters the undated bucket; input never mutated). - **`TimelineFilters.svelte`** — dumb bar: three `$bindable` toggles in a `<fieldset><legend>`, sticky "Filter (N aktiv)" trigger, reset text button (visible only when a layer is off), reduced-motion-aware `slide` (duration 0 when `prefers-reduced-motion`, reusing the guard from `documents/[id]/+page.svelte:57`). Toggles mirror the `SearchFilterBar` undated-toggle markup (`aria-pressed`, ✓ glyph, 44px target, semantic tokens). - **`/zeitstrahl/+page.svelte`** — holds the three toggles in `$state`, binds them into the bar, derives the filtered `TimelineDTO` → `TimelineView`; renders a filter-specific empty-state + one-click reset below the open bar when nothing is visible (never blank, never the generic "no events" state). Meta-line keeps counting the unfiltered timeline (D1 known limitation, commented). - **i18n** — 8 `timeline_filter_*` keys in de/en/es (`trigger` vs `trigger_active({count})` distinct). - **Guards & tests** — a static boundary spec fails the build if either file ever reintroduces `goto`/`url.searchParams`/`api.GET`/`fetch`; an un-skipped Playwright journey + 375px axe (light + dark). ### Requirement coverage All `REQ-001`…`REQ-010` implemented, each with a green test and a `Done` row in `.specify/rtm.md`. Full REQ → commit → test mapping is in the [completion comment on #780](https://git.raddatz.cloud/marcel/familienarchiv/issues/780#issuecomment-17932). ### Verification - **44/44** unit + component tests green (`timelineFilter`, `messages`, `timelineFilterBoundary`, `TimelineFilters.svelte`, `zeitstrahl/page.svelte` — the 7 pre-existing page tests stay green). - `svelte-check`: zero errors in any touched file (repo's ~800 pre-existing baseline is unrelated/ungated). - `prettier`/`eslint` clean — pre-commit gate passed on all 7 commits. ### Reviewer notes - **REQ-009** (reduced-motion) is verified by code review + autofixer, per the spec's own "component / manual" test-matrix designation — only the slide `duration` is zeroed; the matchMedia guard is reused verbatim. - **E2E** (`zeitstrahl-filter.spec.ts`) is authored un-skipped and Playwright-collection-verified, but **not executed** in this environment (local stack unhealthy: backend health 500, no frontend on :3000). Run with the full stack up: `npx playwright test zeitstrahl-filter.spec.ts`. E2E is not wired into CI per `e2e/CLAUDE.md`. - The spec carried a "re-run /review-issue" banner from its 2026-06-14 rescope; implementation proceeded by explicit agreement after verifying the 10 REQs are testable and #779 is merged. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 7 commits 2026-06-14 21:01:03 +02:00
Pure helpers for the /zeitstrahl layer filter: isDefaultState and
hiddenLayerCount drive the "Filter (N active)" trigger, and filterTimeline
derives a client-side view that hides personal/historical/letter layers and
drops year bands left empty. Letters ride the Letters layer, HISTORICAL events
the Historical layer, and curated PERSONAL plus derived life-events the
Personal layer.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eight Paraglide keys for the /zeitstrahl layer filter — layer labels, the
fieldset legend, the sticky trigger (distinct timeline_filter_trigger and
timeline_filter_trigger_active({count}) so it never reads "Filter (0 aktiv)"),
the reset button, and the filtered-empty message — added to all three locales.
messages.spec asserts their presence and the {count} signature.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dumb, client-side layer-filter bar for /zeitstrahl: three $bindable layer
toggles (Personal/Historical/Letters) in a fieldset/legend, a sticky
"Filter (N aktiv)" trigger driven by hiddenLayerCount, and a reset text button
shown only when a layer is off. Toggles mirror the SearchFilterBar undated-toggle
markup (aria-pressed, ✓ glyph, 44px touch target, semantic tokens). The
collapsible slide honours prefers-reduced-motion by zeroing its duration. No
goto, no fetch — the route owns the state and the filtered view.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The route holds the three layer toggles in $state, binds them into
TimelineFilters, and derives a client-side filtered view of the SSR-loaded
timeline that it passes to TimelineView — no goto, no URL param, no extra
fetch. When the active toggles leave nothing visible it renders a calm
filtered-empty message plus a one-click reset below the still-open filter bar,
never a blank page and never the generic "no events" state. The meta-line keeps
counting the unfiltered timeline (D1 known limitation).

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A static boundary gate (mirroring the project's no-`{@html}` greps) that reads
TimelineFilters.svelte and /zeitstrahl/+page.svelte and fails if either ever
reintroduces goto(, url.searchParams, api.GET, or fetch( — the filter must stay
presentation-only and fully client-side.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Playwright spec for /zeitstrahl: the primary journey (hide Letters → letter
cards vanish + trigger reports "1 aktiv" → reset restores) and a 375px axe pass
with the collapsible open in light and dark mode. Not skipped — #779 ships the
route. E2E is not wired into CI, so this runs locally only for now.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
docs(rtm): trace #780 timeline-layer-filter requirements
Some checks failed
SDD Gate / Contract Validate (pull_request) Successful in 25s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
CI / Unit & Component Tests (pull_request) Successful in 4m43s
CI / fail2ban Regex (pull_request) Failing after 47s
CI / OCR Service Tests (pull_request) Successful in 25s
CI / Backend Unit Tests (pull_request) Successful in 5m17s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m10s
SDD Gate / RTM Check (pull_request) Successful in 14s
85cac0f089
Add the ten REQ-001…REQ-010 rows for the /zeitstrahl layer filter, each linked
to its implementation file(s) and test(s), all Done.

Refs #780
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

📋 Requirements Engineer — PR Review

Verdict: ⚠️ Approved with concerns

Every REQ-NNN from the (rescoped 2026-06-14, layer-only) #780 spec is implemented, tested, and carries a Done row freshly added to .specify/rtm.md (10 rows, lines 157–166). Traceability is end-to-end.

Traceability table

REQ Implemented Test RTM Done
REQ-001 presentation-only, no nav/fetch TimelineFilters.svelte TimelineFilters.svelte.spec.ts + timelineFilterBoundary.spec.ts
REQ-002 route derives $derived filtered view +page.svelte:23-28 page.svelte.spec.ts + boundary
REQ-003 Personal off → personal+derived hidden timelineFilter.ts#isVisible timelineFilter.spec.ts, page.svelte.spec.ts
REQ-004 Historical off timelineFilter.ts#isVisible timelineFilter.spec.ts, page.svelte.spec.ts
REQ-005 Letters off timelineFilter.ts#isVisible timelineFilter.spec.ts, page.svelte.spec.ts
REQ-006 zero-visible empty-state + reset below bar +page.svelte:84-98 page.svelte.spec.ts, timelineFilter.spec.ts
REQ-007 sticky "Filter (N aktiv)" via isDefaultState/hiddenLayerCount, 44px timelineFilter.ts, TimelineFilters.svelte timelineFilter.spec.ts, TimelineFilters.svelte.spec.ts
REQ-008 reset text button, $derived any-off TimelineFilters.svelte TimelineFilters.svelte.spec.ts
REQ-009 reduced-motion → duration: 0 TimelineFilters.svelte:24-30 manual / review only
REQ-010 8 i18n keys × 3 locales, trigger vs trigger_active distinct messages/{de,en,es}.json messages.spec.ts

Concerns (not blockers)

  • REQ-009 is the one requirement with no automated test — it's verified by code review + svelte-autofixer. This is as the spec's own test matrix designates it ("component / manual reduced-motion duration: 0"), so it is not a contract violation, but it remains the single requirement with no green assertion. I verified the reuse is faithful: the guard at documents/[id]/+page.svelte:58 is reproduced verbatim and only the slide duration is zeroed.
  • Trivial doc nit: the RTM row and PR body cite the reused guard as documents/[id]/+page.svelte:57; it is actually at line 58 (matching the spec). Worth a one-character fix for future trace accuracy.

Scope-creep check

No behavior without a backing requirement. The extra timelineFilterBoundary.spec.ts and ALL_LAYERS_ON/hiddenLayerCount helpers are all backed by REQ-001/002/007 acceptance criteria (the "grep returns zero" AC). RTM is in sync with main.

### 📋 Requirements Engineer — PR Review **Verdict: ⚠️ Approved with concerns** Every `REQ-NNN` from the (rescoped 2026-06-14, layer-only) #780 spec is implemented, tested, and carries a `Done` row freshly added to `.specify/rtm.md` (10 rows, lines 157–166). Traceability is end-to-end. #### Traceability table | REQ | Implemented | Test | RTM `Done` | |---|---|---|---| | REQ-001 presentation-only, no nav/fetch | `TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts` + `timelineFilterBoundary.spec.ts` | ✅ | | REQ-002 route derives `$derived` filtered view | `+page.svelte:23-28` | `page.svelte.spec.ts` + boundary | ✅ | | REQ-003 Personal off → personal+derived hidden | `timelineFilter.ts#isVisible` | `timelineFilter.spec.ts`, `page.svelte.spec.ts` | ✅ | | REQ-004 Historical off | `timelineFilter.ts#isVisible` | `timelineFilter.spec.ts`, `page.svelte.spec.ts` | ✅ | | REQ-005 Letters off | `timelineFilter.ts#isVisible` | `timelineFilter.spec.ts`, `page.svelte.spec.ts` | ✅ | | REQ-006 zero-visible empty-state + reset below bar | `+page.svelte:84-98` | `page.svelte.spec.ts`, `timelineFilter.spec.ts` | ✅ | | REQ-007 sticky "Filter (N aktiv)" via `isDefaultState`/`hiddenLayerCount`, 44px | `timelineFilter.ts`, `TimelineFilters.svelte` | `timelineFilter.spec.ts`, `TimelineFilters.svelte.spec.ts` | ✅ | | REQ-008 reset text button, `$derived` any-off | `TimelineFilters.svelte` | `TimelineFilters.svelte.spec.ts` | ✅ | | REQ-009 reduced-motion → `duration: 0` | `TimelineFilters.svelte:24-30` | **manual / review only** | ✅ | | REQ-010 8 i18n keys × 3 locales, trigger vs trigger_active distinct | `messages/{de,en,es}.json` | `messages.spec.ts` | ✅ | #### Concerns (not blockers) - **REQ-009 is the one requirement with no automated test** — it's verified by code review + svelte-autofixer. This is *as the spec's own test matrix designates it* ("component / manual reduced-motion `duration: 0`"), so it is not a contract violation, but it remains the single requirement with no green assertion. I verified the reuse is faithful: the guard at `documents/[id]/+page.svelte:58` is reproduced verbatim and only the slide `duration` is zeroed. - **Trivial doc nit:** the RTM row and PR body cite the reused guard as `documents/[id]/+page.svelte:57`; it is actually at **line 58** (matching the spec). Worth a one-character fix for future trace accuracy. #### Scope-creep check No behavior without a backing requirement. The extra `timelineFilterBoundary.spec.ts` and `ALL_LAYERS_ON`/`hiddenLayerCount` helpers are all backed by REQ-001/002/007 acceptance criteria (the "grep returns zero" AC). RTM is in sync with `main`.
Author
Owner

👨‍💻 Developer (Felix Brandt) — PR Review

Verdict: Approved

Clean, small, and idiomatic Svelte 5. The split is exactly right: timelineFilter.ts is a pure module, TimelineFilters.svelte is a dumb presentation component (three $bindable booleans + onChange), and the route owns the $state and the $derived filtered view. KISS over DRY — no premature abstraction.

What I checked

  • Purity / no mutationfilterTimeline rebuilds { years, undated } with .map/.filter and never touches the input; timelineFilter.spec.ts#does not mutate the input timeline proves it. I confirmed TimelineDTO is exactly { years, undated } in generated/api.ts:2432, so the reconstruction loses no field.
  • isVisible branch coveragekind === 'LETTER' is checked before type, which is correct because type? is optional on the DTO (letters have no type). Events fall to HISTORICAL or default-to-personal — sound for the PERSONAL | HISTORICAL enum, and a defensible default if type were ever absent.
  • generate:api correctly skipped — no backend model/endpoint change, api.ts untouched (constitution §3.5/§4.1 respected). No new ErrorCode (none needed) — the four-site rule (§3.6) is N/A.
  • TDD — the issue's completion comment maps each REQ to a red→green commit; the helper/component/page/messages specs are all present and assert real behavior.
  • Atomic commits — 7 commits, one logical change each, all referencing #780.

Suggestions (nice-to-have, non-blocking)

  • +page.svelte:79-81 uses the longhand bind:personalOn={personalOn}. Svelte 5 accepts the shorthand bind:personalOn when the variable name matches — marginally cleaner, but the longhand is valid and prettier-stable, so purely cosmetic.
  • timelineFilter.ts re-declares TimelineDTO/TimelineEntryDTO type aliases that several sibling timeline modules also declare locally. Fine at two callers (KISS, §3.2 — abstract on the third), just noting it for when a third appears.

No layering, error-handling, or generated-artifact issues. Good work.

### 👨‍💻 Developer (Felix Brandt) — PR Review **Verdict: ✅ Approved** Clean, small, and idiomatic Svelte 5. The split is exactly right: `timelineFilter.ts` is a pure module, `TimelineFilters.svelte` is a dumb presentation component (three `$bindable` booleans + `onChange`), and the route owns the `$state` and the `$derived` filtered view. KISS over DRY — no premature abstraction. #### What I checked - **Purity / no mutation** — `filterTimeline` rebuilds `{ years, undated }` with `.map`/`.filter` and never touches the input; `timelineFilter.spec.ts#does not mutate the input timeline` proves it. I confirmed `TimelineDTO` is *exactly* `{ years, undated }` in `generated/api.ts:2432`, so the reconstruction loses no field. - **`isVisible` branch coverage** — `kind === 'LETTER'` is checked before `type`, which is correct because `type?` is optional on the DTO (letters have no type). Events fall to `HISTORICAL` or default-to-personal — sound for the `PERSONAL | HISTORICAL` enum, and a defensible default if `type` were ever absent. - **`generate:api` correctly skipped** — no backend model/endpoint change, `api.ts` untouched (constitution §3.5/§4.1 respected). No new `ErrorCode` (none needed) — the four-site rule (§3.6) is N/A. - **TDD** — the issue's completion comment maps each REQ to a red→green commit; the helper/component/page/messages specs are all present and assert real behavior. - **Atomic commits** — 7 commits, one logical change each, all referencing #780. #### Suggestions (nice-to-have, non-blocking) - `+page.svelte:79-81` uses the longhand `bind:personalOn={personalOn}`. Svelte 5 accepts the shorthand `bind:personalOn` when the variable name matches — marginally cleaner, but the longhand is valid and prettier-stable, so purely cosmetic. - `timelineFilter.ts` re-declares `TimelineDTO`/`TimelineEntryDTO` type aliases that several sibling timeline modules also declare locally. Fine at two callers (KISS, §3.2 — abstract on the third), just noting it for when a third appears. No layering, error-handling, or generated-artifact issues. Good work.
Author
Owner

🧪 Tester — PR Review

Verdict: ⚠️ Approved with concerns

Coverage is broad and the levels are well-chosen: a pure-logic suite (timelineFilter.spec.ts), a static boundary guard (timelineFilterBoundary.spec.ts), a component suite (TimelineFilters.svelte.spec.ts), a route-integration suite (page.svelte.spec.ts, +4 new specs over the 7 pre-existing), message parity (messages.spec.ts), and an un-skipped E2E journey + 375px axe. Each layer tests at the right altitude and asserts real behavior (e.g. does not mutate the input timeline, drops year bands that become empty).

Strengths

  • The boundary spec is the right tool for REQ-001/002's "no navigation/fetch" — a source-level grep gate (/\bgoto\s*\(/, /url\.searchParams/, /\bapi\.GET\b/, /\bfetch\s*\(/) that cannot regress. Stronger than a one-shot runtime spy.
  • page.svelte.spec.ts exercises the real route component with all three layers, including the all-off filtered-empty path and that the generic timeline_empty_state is explicitly not shown (asserts .query() is null) — a sharp distinction.

Concerns (not blockers)

  1. REQ-009 has no automated assertion. Per the spec's test matrix this is "component / manual", so it's contract-compliant — but it sits in tension with constitution §3.1 (all new behavior driven by a failing test). It could be nudged into coverage: stub window.matchMedia to { matches: true } and assert the collapsible opens without the slide delay, or extract a tiny slideDurationFor(reduced: boolean) pure helper and unit-test it. Optional, but it would close the one untested REQ.
  2. REQ-002's "asserts fetch not called" AC is met statically, not at runtime. page.svelte.spec.ts#…with no fetch toggles and checks the DOM but does not spy on createApiClient/fetch; the "no fetch" guarantee rests entirely on timelineFilterBoundary.spec.ts. Acceptable (the static guard is strictly stronger), just noting the AC's literal wording asked for a runtime "not called" check.
  3. E2E was authored but not executed in this environment (local stack unhealthy: backend health 500, no frontend on :3000), and E2E is not CI-wired (e2e/CLAUDE.md). The axe/journey gate is therefore unverified until someone runs npx playwright test zeitstrahl-filter.spec.ts against a healthy stack. I confirmed the spec is collection-valid and not test.skip-ped (the #779 dependency is merged, so removing the skip is correct).

No flaky patterns, no passing-on-first-run tests spotted. Recommend running the E2E locally before merge to close concern #3.

### 🧪 Tester — PR Review **Verdict: ⚠️ Approved with concerns** Coverage is broad and the levels are well-chosen: a pure-logic suite (`timelineFilter.spec.ts`), a static boundary guard (`timelineFilterBoundary.spec.ts`), a component suite (`TimelineFilters.svelte.spec.ts`), a route-integration suite (`page.svelte.spec.ts`, +4 new specs over the 7 pre-existing), message parity (`messages.spec.ts`), and an un-skipped E2E journey + 375px axe. Each layer tests at the right altitude and asserts real behavior (e.g. `does not mutate the input timeline`, `drops year bands that become empty`). #### Strengths - The boundary spec is the right tool for REQ-001/002's "no navigation/fetch" — a source-level grep gate (`/\bgoto\s*\(/`, `/url\.searchParams/`, `/\bapi\.GET\b/`, `/\bfetch\s*\(/`) that *cannot* regress. Stronger than a one-shot runtime spy. - `page.svelte.spec.ts` exercises the real route component with all three layers, including the all-off filtered-empty path and that the **generic** `timeline_empty_state` is explicitly *not* shown (asserts `.query()` is null) — a sharp distinction. #### Concerns (not blockers) 1. **REQ-009 has no automated assertion.** Per the spec's test matrix this is "component / manual", so it's contract-compliant — but it sits in tension with constitution §3.1 (all new behavior driven by a failing test). It *could* be nudged into coverage: stub `window.matchMedia` to `{ matches: true }` and assert the collapsible opens without the slide delay, or extract a tiny `slideDurationFor(reduced: boolean)` pure helper and unit-test it. Optional, but it would close the one untested REQ. 2. **REQ-002's "asserts fetch not called" AC is met statically, not at runtime.** `page.svelte.spec.ts#…with no fetch` toggles and checks the DOM but does not spy on `createApiClient`/`fetch`; the "no fetch" guarantee rests entirely on `timelineFilterBoundary.spec.ts`. Acceptable (the static guard is strictly stronger), just noting the AC's literal wording asked for a runtime "not called" check. 3. **E2E was authored but not executed** in this environment (local stack unhealthy: backend health 500, no frontend on :3000), and E2E is not CI-wired (`e2e/CLAUDE.md`). The axe/journey gate is therefore unverified until someone runs `npx playwright test zeitstrahl-filter.spec.ts` against a healthy stack. I confirmed the spec is collection-valid and not `test.skip`-ped (the #779 dependency is merged, so removing the skip is correct). No flaky patterns, no passing-on-first-run tests spotted. Recommend running the E2E locally before merge to close concern #3.
Author
Owner

🔐 Security (Nora "NullX") — PR Review

Verdict: Approved — LGTM, nil attack surface.

This is a read-only, frontend-only, client-side-only visibility filter over data already loaded by #779. No trust boundary is crossed. I checked everything I'd normally look for and found nothing to fix:

  • No mutating endpoint — no POST/PUT/PATCH/DELETE added, so §2.1/§2.2 (@RequirePermission, typed enum) are N/A. No backend code at all.
  • No new network call / no IDORtimelineFilterBoundary.spec.ts statically forbids api.GET/fetch( in both touched files, and the boundary is reachable only by family-wide read. The +page.server.ts SSR load (and its 401/error handling) is owned by #779 and untouched here.
  • XSS / CWE-79 (§2.5) — surface is nil. Every rendered string is a static Paraglide key (m.timeline_filter_*); the only dynamic value is m.timeline_filter_trigger_active({ count }), where count is a number derived from three booleans (hiddenLayerCount). No user/import-derived text is rendered, and grep -rn '@html' frontend/src/lib/timeline/ stays at zero — {...} escaping throughout.
  • No URL input parsed — the rescope dropped parseTimelineFilters; there is no url.searchParams read (boundary-guarded), so no tampering/open-redirect/DoS-via-params vector.
  • No secrets, no PII in logs — no logging, no env var, no credential, no audit field (createdBy/updatedBy N/A — no persistence).
  • No new dependency@axe-core/playwright and @playwright/test used by the E2E spec are already in the manifest; §5.1 (ADR-for-new-dependency) is not triggered.

The STRIDE assessment in the issue body matches the code as shipped. Nothing for me to block on.

### 🔐 Security (Nora "NullX") — PR Review **Verdict: ✅ Approved** — LGTM, nil attack surface. This is a read-only, frontend-only, **client-side-only** visibility filter over data already loaded by #779. No trust boundary is crossed. I checked everything I'd normally look for and found nothing to fix: - **No mutating endpoint** — no `POST/PUT/PATCH/DELETE` added, so §2.1/§2.2 (`@RequirePermission`, typed enum) are N/A. No backend code at all. - **No new network call / no IDOR** — `timelineFilterBoundary.spec.ts` statically forbids `api.GET`/`fetch(` in both touched files, and the boundary is reachable only by family-wide read. The `+page.server.ts` SSR load (and its 401/error handling) is owned by #779 and untouched here. - **XSS / CWE-79 (§2.5)** — surface is nil. Every rendered string is a static Paraglide key (`m.timeline_filter_*`); the only dynamic value is `m.timeline_filter_trigger_active({ count })`, where `count` is a number derived from three booleans (`hiddenLayerCount`). No user/import-derived text is rendered, and `grep -rn '@html' frontend/src/lib/timeline/` stays at zero — `{...}` escaping throughout. - **No URL input parsed** — the rescope dropped `parseTimelineFilters`; there is no `url.searchParams` read (boundary-guarded), so no tampering/open-redirect/DoS-via-params vector. - **No secrets, no PII in logs** — no logging, no env var, no credential, no audit field (`createdBy`/`updatedBy` N/A — no persistence). - **No new dependency** — `@axe-core/playwright` and `@playwright/test` used by the E2E spec are already in the manifest; §5.1 (ADR-for-new-dependency) is not triggered. The STRIDE assessment in the issue body matches the code as shipped. Nothing for me to block on.
Author
Owner

⚙️ DevOps — PR Review

Verdict: Approved — LGTM, zero infra/operational footprint.

Nothing in this PR touches the deploy, build, or CI surface, and there are no Do-Not-Touch violations.

What I checked (constitution §4 / §5)

  • No generated-artifact editsfrontend/src/lib/generated/api.ts and paraglide/ are untouched (§4.1). The only non-source change is .specify/rtm.md, which is a hand-maintained doc, not generated.
  • No Flyway migration — none added or edited (§4.6); this is frontend-only.
  • No CI guard weakened, no upload/download-artifact bump (§4.3/§4.4) — no .gitea/workflows/ change at all.
  • No new dependency (§5) — package.json unchanged; the E2E spec reuses already-present @axe-core/playwright + @playwright/test.
  • No env var / secret / compose change — nothing for .env.example or docker-compose*.yml.
  • Branch + PR flow honored (§4.5) — work is on feat/issue-780-timeline-layer-filter, not main.

Operational note (not a blocker)

  • The new e2e/zeitstrahl-filter.spec.ts (journey + 375px axe, light/dark) is not wired into CI — per e2e/CLAUDE.md, E2E runs locally only for now. So this axe gate provides no automated regression protection until the E2E suite is CI-integrated; that's an accepted project-wide posture, not a defect of this PR. Just flagging that the a11y guarantee here is manual-run for the foreseeable future.

No rollback risk (no schema/state), no config drift. Ship-safe from an ops standpoint.

### ⚙️ DevOps — PR Review **Verdict: ✅ Approved** — LGTM, zero infra/operational footprint. Nothing in this PR touches the deploy, build, or CI surface, and there are no Do-Not-Touch violations. #### What I checked (constitution §4 / §5) - **No generated-artifact edits** — `frontend/src/lib/generated/api.ts` and `paraglide/` are untouched (§4.1). The only non-source change is `.specify/rtm.md`, which is a hand-maintained doc, not generated. - **No Flyway migration** — none added or edited (§4.6); this is frontend-only. - **No CI guard weakened, no `upload/download-artifact` bump** (§4.3/§4.4) — no `.gitea/workflows/` change at all. - **No new dependency** (§5) — `package.json` unchanged; the E2E spec reuses already-present `@axe-core/playwright` + `@playwright/test`. - **No env var / secret / compose change** — nothing for `.env.example` or `docker-compose*.yml`. - **Branch + PR flow honored** (§4.5) — work is on `feat/issue-780-timeline-layer-filter`, not `main`. #### Operational note (not a blocker) - The new `e2e/zeitstrahl-filter.spec.ts` (journey + 375px axe, light/dark) is **not wired into CI** — per `e2e/CLAUDE.md`, E2E runs locally only for now. So this axe gate provides no automated regression protection until the E2E suite is CI-integrated; that's an accepted project-wide posture, not a defect of this PR. Just flagging that the a11y guarantee here is manual-run for the foreseeable future. No rollback risk (no schema/state), no config drift. Ship-safe from an ops standpoint.
Author
Owner

🎨 UI/UX — PR Review

Verdict: ⚠️ Approved with concerns

The interaction model is right for the dual audience (phone-first readers + seniors 60+), and the toggle markup faithfully mirrors the canonical SearchFilterBar undated-toggle (verified against SearchFilterBar.svelte:262-274). Accessibility fundamentals are solid.

Strong points

  • State is not color-alone (WCAG 1.4.1) — each toggle pairs the bg-primary/text-primary-fg color with a checkbox-style ✓ glyph box (filled when pressed, empty-bordered when not), and aria-pressed carries the state to AT. The glyph is correctly aria-hidden.
  • Touch targets — trigger, all three toggles, and both reset buttons are min-h-[44px] (WCAG 2.5.5). Verified in TimelineFilters.svelte.spec.ts#gives the trigger a 44px touch target.
  • Grouping & disclosure — toggles live in a <fieldset><legend>Ebenen anzeigen</legend>; the collapsible trigger has aria-expanded and a correctly-conditional aria-controls (set only while the panel is in the DOM, avoiding a dangling idref).
  • Reset is a text button, not icon-only (seniors) — and there are two reset affordances (in-panel + in the filtered-empty block), both labelled.
  • Empty state — REQ-006 renders a calm "Keine Einträge entsprechen diesen Filtern." + reset below the still-open bar, and explicitly avoids the generic archive-empty message. Nicely judged.
  • Dark mode — all semantic tokens (bg-surface, border-line, text-ink-2/3, bg-primary, text-primary-fg); no raw palette. Reduced-motion handled (REQ-009).

Concerns

  1. Meta-line count mismatch (D1, documented). The header sub-line ({metaLine} from timelineMeta(data.timeline)) keeps counting the unfiltered timeline. So with Letters off and zero letters visible, the header can still read e.g. "1903–1945 · 12 Briefe · …". For the senior audience this is a plausible "did it actually filter?" confusion. It's an accepted spec limitation (D1) and code-commented at +page.svelte:14-18 — I'm not blocking, but I'd strongly suggest a fast follow-up to either recompute the meta from filteredTimeline or append a "(gefiltert)" hint when !isDefaultState.

Suggestions (non-blocking)

  • The toggle accessible name is the bare layer label ("Persönliche Ereignisse") rather than the spec's illustrative "Historische Ereignisse anzeigen" verb phrasing. In context (legend "Ebenen anzeigen" + aria-pressed) this reads fine, so it's acceptable — but a … anzeigen aria-label would make each button self-describing out of context.
  • Sticky trigger uses top-16 (4rem) to clear the header; worth a manual check at 375px landscape (short viewport) that the sticky bar doesn't eat the small content area — the issue called this out specifically.

No blockers from a UX standpoint; concern #1 is the one I'd like to see tracked.

### 🎨 UI/UX — PR Review **Verdict: ⚠️ Approved with concerns** The interaction model is right for the dual audience (phone-first readers + seniors 60+), and the toggle markup faithfully mirrors the canonical `SearchFilterBar` undated-toggle (verified against `SearchFilterBar.svelte:262-274`). Accessibility fundamentals are solid. #### Strong points - **State is not color-alone (WCAG 1.4.1)** — each toggle pairs the `bg-primary/text-primary-fg` color with a checkbox-style ✓ glyph box (filled when pressed, empty-bordered when not), and `aria-pressed` carries the state to AT. The glyph is correctly `aria-hidden`. - **Touch targets** — trigger, all three toggles, and both reset buttons are `min-h-[44px]` (WCAG 2.5.5). Verified in `TimelineFilters.svelte.spec.ts#gives the trigger a 44px touch target`. - **Grouping & disclosure** — toggles live in a `<fieldset><legend>Ebenen anzeigen</legend>`; the collapsible trigger has `aria-expanded` and a correctly-conditional `aria-controls` (set only while the panel is in the DOM, avoiding a dangling idref). - **Reset is a text button**, not icon-only (seniors) — and there are *two* reset affordances (in-panel + in the filtered-empty block), both labelled. - **Empty state** — REQ-006 renders a calm "Keine Einträge entsprechen diesen Filtern." + reset **below** the still-open bar, and explicitly avoids the generic archive-empty message. Nicely judged. - **Dark mode** — all semantic tokens (`bg-surface`, `border-line`, `text-ink-2/3`, `bg-primary`, `text-primary-fg`); no raw palette. Reduced-motion handled (REQ-009). #### Concerns 1. **Meta-line count mismatch (D1, documented).** The header sub-line (`{metaLine}` from `timelineMeta(data.timeline)`) keeps counting the **unfiltered** timeline. So with Letters off and zero letters visible, the header can still read e.g. "1903–1945 · 12 Briefe · …". For the senior audience this is a plausible "did it actually filter?" confusion. It's an accepted spec limitation (D1) and code-commented at `+page.svelte:14-18` — I'm not blocking, but I'd strongly suggest a fast follow-up to either recompute the meta from `filteredTimeline` or append a "(gefiltert)" hint when `!isDefaultState`. #### Suggestions (non-blocking) - The toggle accessible name is the bare layer label ("Persönliche Ereignisse") rather than the spec's illustrative "Historische Ereignisse **anzeigen**" verb phrasing. In context (legend "Ebenen anzeigen" + `aria-pressed`) this reads fine, so it's acceptable — but a `… anzeigen` aria-label would make each button self-describing out of context. - Sticky trigger uses `top-16` (4rem) to clear the header; worth a manual check at 375px **landscape** (short viewport) that the sticky bar doesn't eat the small content area — the issue called this out specifically. No blockers from a UX standpoint; concern #1 is the one I'd like to see tracked.
Author
Owner

🏛️ Architect — PR Review

Verdict: Approved

The change respects every boundary it touches and introduces no irreversible decision, so no ADR is required.

Boundaries (constitution §1)

  • §1.4 frontend domain split — all new code lives in the timeline domain ($lib/timeline/timelineFilter.ts, TimelineFilters.svelte + specs); the route consumes it. timelineFilter.ts imports only $lib/generated/api types — no cross-domain reach. The route→$lib/timeline import is already permitted by eslint.config.js boundaries/dependencies (the route already imported TimelineView), so no boundary config change was needed or made.
  • Presentation/route split — the dumb component holds no timeline data and never navigates/fetches (boundary-guarded); the route owns $state + the $derived filtered view. This is the layering the issue mandated.
  • No backend domain added — so the §1.7 ArchitectureTest allow-list rule is N/A (no Java touched). No service/repository/controller layering concerns.

Irreversibility / ADRs

  • No new dependency (§5.1 → no ADR), no schema/contract change, no Accepted ADR edited or superseded (§4.2). The GET /api/timeline contract (owned by #5/#835) is unchanged — this filters a fully-materialized DTO client-side.
  • I confirmed TimelineDTO is closed at { years, undated } (generated/api.ts:2432), so filterTimeline reconstructing the DTO is structurally complete and forward-compatible: if the DTO gains a field, the spread-free rebuild would need a touch-up, but today it loses nothing.

Design soundness

  • The "layer" taxonomy (LETTER → Letters, EVENT & HISTORICAL → Historical, else → Personal) maps cleanly onto the existing kind/type/derived model with no new server concept — derived life-events correctly ride the Personal layer (they carry type=PERSONAL per #776), avoiding a phantom fourth layer. This keeps the feature a pure presentation concern, exactly as scoped.

Architecturally clean. No concerns.

### 🏛️ Architect — PR Review **Verdict: ✅ Approved** The change respects every boundary it touches and introduces no irreversible decision, so no ADR is required. #### Boundaries (constitution §1) - **§1.4 frontend domain split** — all new code lives in the `timeline` domain (`$lib/timeline/timelineFilter.ts`, `TimelineFilters.svelte` + specs); the route consumes it. `timelineFilter.ts` imports **only** `$lib/generated/api` types — no cross-domain reach. The route→`$lib/timeline` import is already permitted by `eslint.config.js` `boundaries/dependencies` (the route already imported `TimelineView`), so no boundary config change was needed or made. - **Presentation/route split** — the dumb component holds no timeline data and never navigates/fetches (boundary-guarded); the route owns `$state` + the `$derived` filtered view. This is the layering the issue mandated. - **No backend domain added** — so the §1.7 `ArchitectureTest` allow-list rule is N/A (no Java touched). No service/repository/controller layering concerns. #### Irreversibility / ADRs - No new dependency (§5.1 → no ADR), no schema/contract change, no `Accepted` ADR edited or superseded (§4.2). The `GET /api/timeline` contract (owned by #5/#835) is unchanged — this filters a fully-materialized DTO client-side. - I confirmed `TimelineDTO` is closed at `{ years, undated }` (`generated/api.ts:2432`), so `filterTimeline` reconstructing the DTO is structurally complete and forward-compatible: if the DTO gains a field, the spread-free rebuild would need a touch-up, but today it loses nothing. #### Design soundness - The "layer" taxonomy (`LETTER` → Letters, `EVENT & HISTORICAL` → Historical, else → Personal) maps cleanly onto the existing `kind`/`type`/`derived` model with no new server concept — derived life-events correctly ride the Personal layer (they carry `type=PERSONAL` per #776), avoiding a phantom fourth layer. This keeps the feature a pure presentation concern, exactly as scoped. Architecturally clean. No concerns.
marcel added 1 commit 2026-06-14 21:51:00 +02:00
fix(timeline): track the meta-line counts to the filtered view
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 4m46s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 5m10s
CI / fail2ban Regex (pull_request) Successful in 47s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
SDD Gate / RTM Check (pull_request) Successful in 14s
SDD Gate / Contract Validate (pull_request) Successful in 22s
SDD Gate / Constitution Impact (pull_request) Successful in 17s
096b4a0f4a
The /zeitstrahl header sub-line counted the unfiltered timeline, so a
hidden layer (e.g. Letters off) still showed its entries in the totals
("1 Brief" with no letters on screen) — the documented D1 limitation.
Derive the meta from filteredTimeline so the range and letter/event
counts always match what is actually rendered. hasContent stays on the
full timeline so the filter bar and meta line still appear whenever the
archive has content.

Refs #780

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author
Owner

Follow-up — UI/UX concern #1 (meta-line count mismatch) resolved

Pushed 096b4a0f (fix(timeline): track the meta-line counts to the filtered view).

The header sub-line now derives from filteredTimeline instead of data.timeline, so the range + letter/event counts always match what's on screen — hiding the Letters layer drops "1 Brief" from the totals rather than leaving it stale. This closes the D1 limitation the original spec accepted; the in-code D1 comment is replaced accordingly.

  • hasContent deliberately stays on the unfiltered timeline, so the filter bar + meta line still appear whenever the archive has content (including the all-filtered-out state, where the empty-state message renders below).
  • TDD red→green: new page.svelte.spec.ts › recomputes the meta-line counts from the filtered view… (confirmed red against the old code). 12/12 page specs green — the 6 pre-existing #833 meta tests (range/singular/zero-drop/empty) still pass, so the default all-on view is byte-identical.
  • Prettier + eslint clean (pre-commit gate passed); Svelte autofixer reports no issues.

Remaining open items from the review are both non-blocking: REQ-009's manual-only coverage (spec-sanctioned) and running the E2E (zeitstrahl-filter.spec.ts) locally against a healthy stack.

### ✅ Follow-up — UI/UX concern #1 (meta-line count mismatch) resolved Pushed `096b4a0f` (`fix(timeline): track the meta-line counts to the filtered view`). The header sub-line now derives from `filteredTimeline` instead of `data.timeline`, so the range + letter/event counts always match what's on screen — hiding the Letters layer drops "1 Brief" from the totals rather than leaving it stale. This **closes the D1 limitation** the original spec accepted; the in-code D1 comment is replaced accordingly. - `hasContent` deliberately stays on the unfiltered timeline, so the filter bar + meta line still appear whenever the archive has content (including the all-filtered-out state, where the empty-state message renders below). - **TDD red→green**: new `page.svelte.spec.ts › recomputes the meta-line counts from the filtered view…` (confirmed red against the old code). **12/12** page specs green — the 6 pre-existing #833 meta tests (range/singular/zero-drop/empty) still pass, so the default all-on view is byte-identical. - Prettier + eslint clean (pre-commit gate passed); Svelte autofixer reports no issues. Remaining open items from the review are both non-blocking: REQ-009's manual-only coverage (spec-sanctioned) and running the E2E (`zeitstrahl-filter.spec.ts`) locally against a healthy stack.
marcel merged commit ec0e4dfa45 into main 2026-06-14 22:11:39 +02:00
marcel deleted branch feat/issue-780-timeline-layer-filter 2026-06-14 22:11:39 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#843