feat(documents): timeline date-range filter with density bars (#385) #478

Merged
marcel merged 52 commits from feat/issue-385-timeline-density-filter into main 2026-05-08 12:27:17 +02:00
Owner

Closes #385.

Adds a horizontal density timeline above /documents showing per-month document counts. Click, drag, zoom, and filter — all reflected in the URL so views are sharable and bookmarkable. Hidden on mobile and tablet (below lg, 1024px) and in calendar view (no fetch cost paid where the widget isn't shown).

What ships

BackendGET /api/documents/density

  • New DocumentDensityResult + MonthBucket records.
  • Filter-reactive: accepts q, senderId, receiverId, tag, tagQ, status, tagOp and returns buckets matching the same predicates the document list uses (excluding from/to, since those are what the user is selecting from the chart).
  • Cache-Control: private, max-age=300.
  • In-memory aggregation today; revisit at >50k rows (TODO in DocumentService.getDensity Javadoc).

FrontendTimelineDensityFilter.svelte

  • Pure helpers in timeline.ts: month-sequence builder, gap-filler, year-aggregator, range-clipper, tick-index picker, label formatter.
  • CSR load via +page.ts so the chart never blocks the SSR'd document list.
  • Year-aggregation fallback when the visible range exceeds 240 months (the production archive spans 1873→2023, ≈1809 months — month bars would compress to sub-pixel widths).
  • Graylog-style drag-to-zoom range selector: drag commits filter + zoom atomically with a mint-bordered window growing live as you drag.
  • Single click on a month bar filters that month; single click on a year bar zooms into its 12 months.
  • X/Y axis labels, dynamic by mode (decadal years for long ranges, January boundaries for multi-year month ranges, evenly spaced ticks for a 12-month zoom).
  • Reset-zoom button (↩) restores the full timeline while preserving the active filter.

Test plan

  • cd backend && ./mvnw test — 1540 tests, all green
  • cd backend && ./mvnw clean package -DskipTests — clean build
  • cd frontend && npm run generate:apiDocumentDensityResult + MonthBucket regenerated
  • cd frontend && npm run check — no new TypeScript errors in touched files
  • cd frontend && npm run testTimelineDensityFilter.svelte.spec.ts 35/35 ✓, timeline.spec.ts 44/44 ✓, routes/documents/page.svelte.spec.ts 12/12 ✓
  • cd frontend && npm run lint — passes
  • Manual: drag across years on the production archive (1873→2023) — bar window expands smoothly, release zooms into the dragged range
  • Manual: click a single year in year-mode — timeline rerenders showing 12 month bars with Jan Feb Mär… axis labels
  • Manual: add a sender filter — density refetches and bars rescale to that sender's documents
  • Manual: ↩ reset-zoom restores the full timeline; × clear restores the unfiltered list
  • Manual: mobile / tablet (<1024px) and ?view=calendar both hide the widget and skip the density fetch

Follow-up issues

  • #479 — keyboard-accessible range zoom for timeline (Leonie WCAG 2.1.1)
  • #480 — Playwright E2E + axe coverage for timeline interactions (Sara, Elicit)

🤖 Generated with Claude Code

Closes #385. Adds a horizontal density timeline above `/documents` showing per-month document counts. Click, drag, zoom, and filter — all reflected in the URL so views are sharable and bookmarkable. Hidden on mobile and tablet (below lg, 1024px) and in calendar view (no fetch cost paid where the widget isn't shown). ## What ships **Backend** — `GET /api/documents/density` - New `DocumentDensityResult` + `MonthBucket` records. - Filter-reactive: accepts `q`, `senderId`, `receiverId`, `tag`, `tagQ`, `status`, `tagOp` and returns buckets matching the same predicates the document list uses (excluding `from`/`to`, since those are what the user is *selecting* from the chart). - `Cache-Control: private, max-age=300`. - In-memory aggregation today; revisit at >50k rows (TODO in `DocumentService.getDensity` Javadoc). **Frontend** — `TimelineDensityFilter.svelte` - Pure helpers in `timeline.ts`: month-sequence builder, gap-filler, year-aggregator, range-clipper, tick-index picker, label formatter. - CSR load via `+page.ts` so the chart never blocks the SSR'd document list. - Year-aggregation fallback when the visible range exceeds 240 months (the production archive spans 1873→2023, ≈1809 months — month bars would compress to sub-pixel widths). - Graylog-style drag-to-zoom range selector: drag commits filter + zoom atomically with a mint-bordered window growing live as you drag. - Single click on a month bar filters that month; single click on a year bar zooms into its 12 months. - X/Y axis labels, dynamic by mode (decadal years for long ranges, January boundaries for multi-year month ranges, evenly spaced ticks for a 12-month zoom). - Reset-zoom button (↩) restores the full timeline while preserving the active filter. ## Test plan - [x] `cd backend && ./mvnw test` — 1540 tests, all green - [x] `cd backend && ./mvnw clean package -DskipTests` — clean build - [x] `cd frontend && npm run generate:api` — `DocumentDensityResult` + `MonthBucket` regenerated - [x] `cd frontend && npm run check` — no new TypeScript errors in touched files - [x] `cd frontend && npm run test` — `TimelineDensityFilter.svelte.spec.ts` 35/35 ✓, `timeline.spec.ts` 44/44 ✓, `routes/documents/page.svelte.spec.ts` 12/12 ✓ - [x] `cd frontend && npm run lint` — passes - [ ] Manual: drag across years on the production archive (1873→2023) — bar window expands smoothly, release zooms into the dragged range - [ ] Manual: click a single year in year-mode — timeline rerenders showing 12 month bars with `Jan Feb Mär…` axis labels - [ ] Manual: add a sender filter — density refetches and bars rescale to that sender's documents - [ ] Manual: ↩ reset-zoom restores the full timeline; × clear restores the unfiltered list - [ ] Manual: mobile / tablet (<1024px) and `?view=calendar` both hide the widget and skip the density fetch ## Follow-up issues - #479 — keyboard-accessible range zoom for timeline (Leonie WCAG 2.1.1) - #480 — Playwright E2E + axe coverage for timeline interactions (Sara, Elicit) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 19 commits 2026-05-08 08:56:13 +02:00
Issue #385 introduces GET /api/documents/density which aggregates documents
by month via date_trunc. Adding the index now keeps the query cheap as the
archive grows and removes a future-investigation tax.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Response shape for the upcoming GET /api/documents/density endpoint.
minDate and maxDate are nullable (null on empty archive); buckets is always
present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Native SQL aggregations backing GET /api/documents/density:
- findDensityByMonth groups documents by truncated meta_date with optional
  from/to bounds (frontend fills zero-count gaps).
- findMinMaxDocumentDate returns the earliest/latest meta_date via projection,
  null on empty archive.

Covered by DocumentDensityIntegrationTest (Testcontainers PostgreSQL): empty
archive, single+multi-month grouping, from/to bounds, null meta_date exclusion,
min/max edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Maps the repository's Object[] rows into a DocumentDensityResult and pairs
them with the archive-wide min/max meta_date range. Read-only, no
@Transactional needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Authenticated read endpoint backing the timeline density widget. Optional
from/to LocalDate query params narrow the aggregation. Response carries
Cache-Control: private, max-age=300 so repeated browse sessions skip the
aggregation query (per Tobias' devops review). No @RequirePermission needed —
inherits the global anyRequest().authenticated() rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds DocumentDensityResult, MonthBucket and the /api/documents/density path
to the openapi-typescript output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five new keys across de/en/es for the upcoming TimelineDensityFilter:
aria label, clear selection, abbreviated count label, loading state, and
parametrised filtered-count message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pure utilities backing the TimelineDensityFilter component:
- monthBoundaryFrom/To convert YYYY-MM into LocalDate strings the existing
  /api/documents/search accepts (first/last day of the month).
- buildMonthSequence enumerates months between minDate and maxDate, crossing
  year boundaries.
- fillDensityGaps merges sparse backend buckets with the full month sequence,
  producing zero-count entries for months that the API omitted.

14 unit tests cover leap years, year boundaries, null inputs, and out-of-order
buckets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The density data is fetched only on tablet/desktop (sm:+ breakpoint) and
when ?view=calendar is not set — mobile users and the future calendar view
(#386) skip the request entirely. Lives in +page.ts (client-side) so the
matchMedia gate can run in the browser; +page.server.ts continues to handle
the document search.

Non-ok responses and network failures degrade to an empty bucket list
rather than throwing, so the document list keeps rendering.

5 unit tests cover the gating + graceful degradation paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Density timeline widget: one bar per month within minDate/maxDate,
proportional heights, click-to-select-month with onchange callback,
and a clear button when a selection is active.

Notable details:
- Hidden entirely when density is null (mobile / calendar view; +page.ts
  controls the gating).
- Zero-count months render at 2 px so the time axis stays readable
  (Leonie's design intent overrides AC's literal "no bar" wording).
- Component-scoped --timeline-bar-idle CSS var for the dim idle color
  (light: mint-tinted rgba; dark: structural navy #0d3358 — meets
  WCAG 1.4.11 3:1 against surface, unlike the spec's #0E2535).
- Clear button is a real <button> with aria-label per Nora's a11y note.
- Bars are <button>s with aria-pressed selection state.
- Drag-range, tooltip, and year-tick labels are deferred for follow-ups —
  the AC-required behaviours (click filter, clear, AND-with-other-filters)
  are all in.

11 vitest-browser tests cover visibility gating, bar rendering with
gap-fill, zero-height floor, and selection/clear callback paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mounts the timeline above the result count, hidden on mobile via
\`hidden sm:block\` (defense-in-depth — +page.ts already gates the fetch).
The component's onchange callback updates local from/to and triggers
the existing search reload, so timeline selection composes with the
SearchFilterBar's other filters via AND semantics for free.

3 new page-level integration tests cover: widget renders when density
present, hides when null, and bar click navigates with correct
from/to URL params.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- l3-backend-3b: extend DocumentController description to include the
  per-month density aggregation endpoint.
- l3-frontend-3b: add /documents/+page.ts (client-side gated loader) and
  TimelineDensityFilter component, plus relationships to the density
  endpoint and the search dashboard.

Per Markus' follow-up §5: both diagrams are mandatory before merge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SvelteKit's PageData type generation only picks up +page.ts return values
when both files exist, so the runtime-merged server data was invisible to
TypeScript and svelte-check flagged every q/from/to/etc access in
+page.svelte. Spreading data into the +page.ts return restores the merge
at the type level. No runtime behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Surfaced during proofshot: the production archive spans 1873 → 2023
(≈1809 month bars). With flex-1 + gap-px on a 1280 px container, every
pixel was consumed by gaps and bars rendered at 0 px width — visible as
"empty box, no bars".

Fix:
- Add aggregateToYears(buckets) that sums month counts per year and
  returns YYYY-keyed entries.
- Add selectionBoundaryFrom/To that handle both YYYY and YYYY-MM labels
  (Jan 1 → Dec 31 for years, first → last day for months).
- Component switches to year granularity when the gap-filled month
  sequence exceeds 240 entries (~20 years), keeping each bar clickable.
- Drop the gap-px between bars and add min-w-px so sub-pixel rounding
  still leaves something visible.

5 new tests cover aggregation, boundary helpers, and the component-level
year-mode + click behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Density bars now recompute when other filters change so the chart always
matches the list it sits above. Selectable filters: q, senderId, receiverId,
tag (multi), tagQ, status, tagOp. Date bounds (from/to) are deliberately
omitted — the chart is the surface for picking those, so it must always
span the broader space the user is selecting within.

Architectural shift: drop the native SQL GROUP BY in favour of in-memory
grouping over the existing Specification-driven findAll. This composes for
free with all the search predicates (FTS-rank-then-filter, sender/receiver,
tag-with-descendants, tagQ partial match, status, tagOp) and keeps the
density implementation a thin layer on top of searchDocuments. At the
current archive size (~5k docs) this stays well under the p95 200ms target;
Cache-Control: max-age=300 absorbs repeated browse loads.

- Removes findDensityByMonth, findMinMaxDocumentDate, DocumentDateRangeProjection.
- Replaces DocumentService.getDensity(LocalDate, LocalDate) with the
  filter-aware overload.
- Endpoint accepts the same query params as /api/documents/search minus
  paging+sort+from+to.
- DocumentDensityIntegrationTest rewritten as @SpringBootTest covering
  no-filter / sender / tag / status / sender+tag combos via real PostgreSQL.
- DocumentServiceTest unit tests updated to the new signature.
- DocumentControllerTest tests forwarding of senderId+tag+tagOp and q+status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The +page.ts client-side load now forwards the active /documents URL
filters (q, senderId, receiverId, tag, tagQ, status, tagOp) to
/api/documents/density so the bars recompute when the user narrows the
search. Date bounds (from/to) are deliberately omitted — the chart is
the surface for picking those.

- New `DensityFilters` type and `buildDensityUrl(filters)` helper.
- `fetchDensity` accepts a filter snapshot (defaulting to {} for
  back-compat in tests).
- 6 new unit tests cover URL building, multi-tag repetition, AND/OR
  forwarding, the explicit-no-from/to invariant, and filter-aware fetch.
- Generated API types refreshed against the new backend signature.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The original AC required drag-to-select; the MVP shipped with click-only.
This adds pointer-driven range selection while preserving keyboard access:

- Pointer events (pointerdown / pointerenter / pointerup) drive the drag.
  Pointer capture on pointerdown so the cursor leaving the bar still
  produces drag-end events. Live preview class `in-drag-preview` highlights
  the spanning bars while dragging; the URL/list refetch only fires on
  pointerup (Felix R3).
- Click handler kept for keyboard activation (Enter/Space on focused bar).
  A `suppressClick` flag prevents the synthesized click after a mouse
  pointerup from double-emitting.
- Drag from later → earlier still emits ascending boundaries (drag direction
  doesn't matter).
- Existing single-click keyboard selection unchanged.

4 new component tests cover the drag paths plus the live-preview class.
Existing 13 tests (single click, year mode, clear, visibility) still green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a zoom action that narrows the visible timeline range to the current
selection so the user can drill from year-level back into month-level
density. Zoom state lives in the URL (zoomFrom / zoomTo) so it survives
reload and is shareable.

- New `clipBucketsToRange(buckets, from, to)` helper applied before the
  >240-month year-aggregate decision, so a zoomed window flips back to
  month bars automatically when the clip narrows the range enough.
- `TimelineDensityFilter` gains `zoomFrom`, `zoomTo`, and `onzoomchange`
  props. Zoom button shown only when a selection exists and we aren't
  already zoomed; reset-zoom shown only when zoomed. Both placed in a
  shared right-edge action cluster alongside the × clear button.
- `+page.ts` reads zoomFrom/zoomTo from the URL and forwards them as
  props. `+page.svelte` extends FilterSnapshot + buildSearchParams, and
  triggerSearch accepts an optional zoom override so the onzoomchange
  callback can write the new pair (or clear them) atomically.
- 7 new component tests + 2 new page-integration tests cover the
  visibility rules and URL writes.
- 4 new unit tests for `clipBucketsToRange`.
- 3 new i18n keys (zoom in / zoom reset / drag aria-live) across de/en/es.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(documents): rework timeline UX after live testing (#385)
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m29s
CI / OCR Service Tests (push) Successful in 48s
CI / Backend Unit Tests (push) Failing after 3m46s
CI / Unit & Component Tests (pull_request) Failing after 4m31s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m31s
5d92f5a32b
Replaces the discrete zoom-in button with a Graylog-style drag-to-zoom
range selector and adds X/Y axis labels so the chart is readable.

Drag interaction
- Pointerdown on a bar attaches document-level pointermove/pointerup/
  pointercancel listeners; pointermove maps clientX to a bar index via
  the row's bounding rect, so the mint-bordered window expands smoothly
  even when the cursor leaves the bar or the chart entirely.
- pointerup commits filter + zoom atomically. Same-bar release on a
  year bar (year-aggregated mode) zooms into that year's months;
  same-bar release on a month bar emits filter-only.
- setPointerCapture removed — it was suppressing pointerenter on
  sibling bars and preventing the drag window from expanding.
- Bar buttons are now h-full so the entire 80 px column is the hit
  target, not just the visible bar height.

Axis labels
- Y-axis: max-count and 0 labels left of the bar area.
- X-axis: tickIndicesFor() picks decadal years for long ranges, evenly
  spaced months for short year-zoom views, January boundaries for
  multi-year month ranges. formatTickLabel() drops the year when the
  visible range is a single year so 12-month zooms read "Jan Feb Mär…".

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

🏛️ Markus Keller — Senior Application Architect

Verdict: ⚠️ Approved with concerns

Solid endpoint, clean records, correct documentation hygiene (the C4 PUMLs for both backend and frontend are updated, which is exactly the table-driven discipline I expect). Two architectural concerns worth flagging — one structural, one schema-related.

Concerns (not blockers)

  1. In-memory aggregation in DocumentService.getDensitybackend/.../DocumentService.java:163-172

    List<LocalDate> dates = documentRepository.findAll(spec).stream()
            .map(Document::getDocumentDate)
            .filter(Objects::nonNull)
            .toList();
    

    This pulls every matching Document row (with its eagerly fetched joins for sender/receivers/tags through the Specification) into the JVM, then groups in Java. The Javadoc explicitly justifies this against the 200ms target with "≈5k docs". That's fine today. The right shape is a SQL aggregation:

    SELECT TO_CHAR(meta_date, 'YYYY-MM') AS month, COUNT(*) AS cnt
    FROM documents WHERE <spec predicates>
    GROUP BY 1 ORDER BY 1
    

    The Specification API doesn't compose with raw SQL aggregations cleanly, so I accept the trade-off — but please add a TODO with a row-count threshold (e.g. // revisit when documents > 50k) so it surfaces during the next perf audit. The new idx_documents_meta_date is a good defensive move regardless.

  2. The endpoint shape duplicates filter-parameter plumbing across /search and /density — same seven query params, parsed twice (controller + service) into the same Specification<Document> via buildSearchSpec. The duplication is bounded today, but if a third aggregation endpoint ever lands, extract a DocumentSearchCriteria record before adding it.

LGTM

  • DocumentDensityResult and MonthBucket are flat records in the document package — correct, no over-engineered DTO layering.
  • V61__add_document_date_index.sql correctly uses IF NOT EXISTS and includes a header comment explaining the documentDate → meta_date mapping. That comment will save the next archaeologist 20 minutes.
  • The tagOp string parsing in the controller mirrors the existing /search endpoint — consistency wins over surface elegance.
  • C4 diagram updates: backend description correctly mentions the new aggregation, frontend diagram introduces docsListPageTs and timelineFilter components with their relationships. No ADR needed — this is a feature, not an architectural decision with lasting consequences.

Doc completeness check

Trigger Required Status
New Flyway migration adding a table/column DB diagrams N/A — index only, no schema model change
New backend controller method l3-backend-3b Updated
New SvelteKit route l3-frontend-3b Updated (added +page.ts + TimelineDensityFilter)
New ErrorCode/Permission CLAUDE.md N/A

— Markus

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ⚠️ Approved with concerns** Solid endpoint, clean records, correct documentation hygiene (the C4 PUMLs for both backend and frontend are updated, which is exactly the table-driven discipline I expect). Two architectural concerns worth flagging — one structural, one schema-related. ### Concerns (not blockers) 1. **In-memory aggregation in `DocumentService.getDensity`** — `backend/.../DocumentService.java:163-172` ```java List<LocalDate> dates = documentRepository.findAll(spec).stream() .map(Document::getDocumentDate) .filter(Objects::nonNull) .toList(); ``` This pulls every matching `Document` row (with its eagerly fetched joins for sender/receivers/tags through the Specification) into the JVM, then groups in Java. The Javadoc explicitly justifies this against the 200ms target with "≈5k docs". That's fine *today*. The right shape is a SQL aggregation: ```sql SELECT TO_CHAR(meta_date, 'YYYY-MM') AS month, COUNT(*) AS cnt FROM documents WHERE <spec predicates> GROUP BY 1 ORDER BY 1 ``` The Specification API doesn't compose with raw SQL aggregations cleanly, so I accept the trade-off — but please add a TODO with a row-count threshold (e.g. `// revisit when documents > 50k`) so it surfaces during the next perf audit. The new `idx_documents_meta_date` is a good defensive move regardless. 2. **The endpoint shape duplicates filter-parameter plumbing across `/search` and `/density`** — same seven query params, parsed twice (controller + service) into the same `Specification<Document>` via `buildSearchSpec`. The duplication is bounded today, but if a third aggregation endpoint ever lands, extract a `DocumentSearchCriteria` record before adding it. ### LGTM - `DocumentDensityResult` and `MonthBucket` are flat records in the document package — correct, no over-engineered DTO layering. - `V61__add_document_date_index.sql` correctly uses `IF NOT EXISTS` and includes a header comment explaining the `documentDate → meta_date` mapping. That comment will save the next archaeologist 20 minutes. - The `tagOp` string parsing in the controller mirrors the existing `/search` endpoint — consistency wins over surface elegance. - C4 diagram updates: backend description correctly mentions the new aggregation, frontend diagram introduces `docsListPageTs` and `timelineFilter` components with their relationships. No ADR needed — this is a feature, not an architectural decision with lasting consequences. ### Doc completeness check | Trigger | Required | Status | |---|---|---| | New Flyway migration adding a table/column | DB diagrams | ✅ N/A — index only, no schema model change | | New backend controller method | l3-backend-3b | ✅ Updated | | New SvelteKit route | l3-frontend-3b | ✅ Updated (added `+page.ts` + `TimelineDensityFilter`) | | New `ErrorCode`/`Permission` | CLAUDE.md | ✅ N/A | — Markus
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

The TDD evidence is strong — backend tests by layer (service unit, controller slice, integration with Testcontainers), frontend tests at three altitudes (timeline.ts pure helpers, component spec, page spec). Names read as sentences. Factory functions used. I'd like a few cleanups before merge.

Concerns (not blockers, but please address)

  1. TimelineDensityFilter.svelte is 377 lines — too many visual concerns in one filefrontend/src/lib/document/TimelineDensityFilter.svelte
    The component owns: bar rendering, Y-axis, X-axis ticks, drag-window overlay, drag/click pointer choreography, year-aggregation fallback, zoom-reset and clear controls. By the rule of "one nameable visual region per .svelte file," that's at least four:

    TimelineDensityFilter.svelte    -- orchestrator (props, state, handlers)
    TimelineBars.svelte             -- the row of bars + drag-window overlay
    TimelineAxes.svelte             -- Y-axis labels + X-axis ticks
    TimelineControls.svelte         -- reset-zoom + clear buttons
    

    The pointer-choreography logic (handleDocumentMove, indexFromClientX, cleanupDragListeners) is genuinely tricky and should stay in the orchestrator. Splitting will also let you delete several data-testids in favor of getByRole-style queries. Not a blocker because the code works and is well-tested, but the next non-trivial change here will hurt.

  2. Five i18n keys added but never usedfrontend/messages/{de,en,es}.json

    timeline_count_label          -- not referenced anywhere in src/
    timeline_loading              -- not referenced
    timeline_filtered_count       -- not referenced
    timeline_zoom_in              -- referenced only by a deleted test assertion
    timeline_dragging_aria_live   -- not referenced (no aria-live region exists)
    

    git grep -E 'timeline_(count_label|loading|filtered_count|zoom_in|dragging_aria_live)' frontend/src returns zero matches. Either wire them up or delete them — three locales × five keys = 15 dead strings to translate the next time we refresh i18n.

  3. aria-label="1915-08 · 5" on each bar is not localizedTimelineDensityFilter.svelte:263

    aria-label="{bucket.month} · {bucket.count}"
    

    Screen readers will announce a bare YYYY-MM string and a number. Coordinate with Leonie's review — at minimum format with formatTickLabel(bucket.month, getLocale()) and an i18n template like m.timeline_bar_aria({ month, count }).

  4. triggerSearch(zoomOverride?: …) adds optional behavior to a function with two callers
    The +page.svelte onchange handler calls triggerSearch({...}) for drag (filter+zoom atomic) and triggerSearch() for click (filter only). The branching reads "depends on whether the source event happened to include zoomFrom/zoomTo," which is implicit. Two named functions (triggerSearchKeepZoom() / triggerSearchWithZoom(from, to)) would make the contract explicit at the call site. Minor — defer if you want.

LGTM

  • Backend getDensity is short, has guard clauses, uses Optional-equivalent nullity handling cleanly. The FTS short-circuit (if (rankedIds.isEmpty()) return …) is exactly the right early-return shape.
  • timeline.ts is pure functions only — every helper is independently testable, and the spec exercises 42 cases. aggregateToYears and tickIndicesFor strategy comments explain why, not what. Good.
  • clipBucketsToRange returns the input array unchanged when bounds are null (return buckets, identity-equal in the test) — tiny detail, but the test pinning that with toBe rather than toEqual is the right discipline.
  • +page.ts correctly uses browser to gate the desktop-only fetch, and fetchDensity degrades to EMPTY on non-ok / network failure rather than throwing — the document list keeps rendering. ✓
  • TS regenerated (api.ts now has DocumentDensityResult + MonthBucket schemas and the density operation).

— Felix

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** The TDD evidence is strong — backend tests by layer (service unit, controller slice, integration with Testcontainers), frontend tests at three altitudes (timeline.ts pure helpers, component spec, page spec). Names read as sentences. Factory functions used. I'd like a few cleanups before merge. ### Concerns (not blockers, but please address) 1. **`TimelineDensityFilter.svelte` is 377 lines — too many visual concerns in one file** — `frontend/src/lib/document/TimelineDensityFilter.svelte` The component owns: bar rendering, Y-axis, X-axis ticks, drag-window overlay, drag/click pointer choreography, year-aggregation fallback, zoom-reset and clear controls. By the rule of "one nameable visual region per `.svelte` file," that's at least four: ``` TimelineDensityFilter.svelte -- orchestrator (props, state, handlers) TimelineBars.svelte -- the row of bars + drag-window overlay TimelineAxes.svelte -- Y-axis labels + X-axis ticks TimelineControls.svelte -- reset-zoom + clear buttons ``` The pointer-choreography logic (`handleDocumentMove`, `indexFromClientX`, `cleanupDragListeners`) is genuinely tricky and should stay in the orchestrator. Splitting will also let you delete several `data-testid`s in favor of `getByRole`-style queries. Not a blocker because the code works and is well-tested, but the next non-trivial change here will hurt. 2. **Five i18n keys added but never used** — `frontend/messages/{de,en,es}.json` ``` timeline_count_label -- not referenced anywhere in src/ timeline_loading -- not referenced timeline_filtered_count -- not referenced timeline_zoom_in -- referenced only by a deleted test assertion timeline_dragging_aria_live -- not referenced (no aria-live region exists) ``` `git grep -E 'timeline_(count_label|loading|filtered_count|zoom_in|dragging_aria_live)' frontend/src` returns zero matches. Either wire them up or delete them — three locales × five keys = 15 dead strings to translate the next time we refresh i18n. 3. **`aria-label="1915-08 · 5"` on each bar is not localized** — `TimelineDensityFilter.svelte:263` ```svelte aria-label="{bucket.month} · {bucket.count}" ``` Screen readers will announce a bare YYYY-MM string and a number. Coordinate with Leonie's review — at minimum format with `formatTickLabel(bucket.month, getLocale())` and an i18n template like `m.timeline_bar_aria({ month, count })`. 4. **`triggerSearch(zoomOverride?: …)` adds optional behavior to a function with two callers** The `+page.svelte` `onchange` handler calls `triggerSearch({...})` for drag (filter+zoom atomic) and `triggerSearch()` for click (filter only). The branching reads "depends on whether the source event happened to include `zoomFrom`/`zoomTo`," which is implicit. Two named functions (`triggerSearchKeepZoom()` / `triggerSearchWithZoom(from, to)`) would make the contract explicit at the call site. Minor — defer if you want. ### LGTM - Backend `getDensity` is short, has guard clauses, uses `Optional`-equivalent nullity handling cleanly. The FTS short-circuit (`if (rankedIds.isEmpty()) return …`) is exactly the right early-return shape. - `timeline.ts` is pure functions only — every helper is independently testable, and the spec exercises 42 cases. `aggregateToYears` and `tickIndicesFor` strategy comments explain *why*, not *what*. Good. - `clipBucketsToRange` returns the input array unchanged when bounds are null (`return buckets`, identity-equal in the test) — tiny detail, but the test pinning that with `toBe` rather than `toEqual` is the right discipline. - `+page.ts` correctly uses `browser` to gate the desktop-only fetch, and `fetchDensity` degrades to `EMPTY` on non-ok / network failure rather than throwing — the document list keeps rendering. ✓ - TS regenerated (`api.ts` now has `DocumentDensityResult` + `MonthBucket` schemas and the `density` operation). — Felix
Author
Owner

🛡️ Nora Steiner ("NullX") — Application Security Engineer

Verdict: Approved

I checked: authn/authz on the new endpoint, injection surface, sensitive-data exposure, cache leakage, the tagOp string parameter, and the user-controlled URL params. Clean.

What I checked

  • AuthenticationGET /api/documents/density has no @RequirePermission annotation. That's correct: per the project convention @RequirePermission is required only on POST/PUT/PATCH/DELETE, and SecurityConfig.anyRequest().authenticated() enforces auth on all /api/** GETs. The controller test density_returns401_whenUnauthenticated pins this — anonymous requests get 401. ✓

  • SQL injection — every filter goes through the existing buildSearchSpec which uses JPA Specification predicates with bound parameters. No string concatenation reaches Hibernate. The new tagOp parameter is String-typed but only used in "OR".equalsIgnoreCase(tagOp) — no shape where a malicious value can reach the query. ✓

  • Information disclosure — the response (buckets, minDate, maxDate) reveals only what the user could already see by paging through /api/documents/search with the same filters. No new data exposure. ✓

  • Cache leakageCache-Control: private, max-age=300 is correct for an authenticated, per-user-filtered response. private keeps shared proxies (Caddy, any future CDN) from caching one user's response and returning it to another. The controller test verifies both private and max-age=300 headers are emitted. ✓

  • CSRF / SSRF — GET endpoint, no state mutation, no outbound requests. N/A.

  • Logging — no user input is logged in the new code path.

  • Frontend fetchDensity — uses the fetch injected by SvelteKit's load function, not onMount + raw fetch. Auth cookie forwarding works correctly (handled by the SvelteKit hook layer). The graceful failure mode (return EMPTY on non-ok) does not leak status codes or backend error shapes to the user. ✓

Suggestion (defer-OK)

The aria-pressed and aria-label attributes on the bar buttons are user-visible state. Make sure XSS through a hypothetical future custom locale formatter isn't possible — currently formatTickLabel uses Intl.DateTimeFormat which is safe, and bucket months come from server-validated YYYY-MM strings. No action needed today.

— Nora

## 🛡️ Nora Steiner ("NullX") — Application Security Engineer **Verdict: ✅ Approved** I checked: authn/authz on the new endpoint, injection surface, sensitive-data exposure, cache leakage, the `tagOp` string parameter, and the user-controlled URL params. Clean. ### What I checked - **Authentication** — `GET /api/documents/density` has no `@RequirePermission` annotation. That's correct: per the project convention `@RequirePermission` is required only on POST/PUT/PATCH/DELETE, and `SecurityConfig.anyRequest().authenticated()` enforces auth on all `/api/**` GETs. The controller test `density_returns401_whenUnauthenticated` pins this — anonymous requests get 401. ✓ - **SQL injection** — every filter goes through the existing `buildSearchSpec` which uses JPA `Specification` predicates with bound parameters. No string concatenation reaches Hibernate. The new `tagOp` parameter is `String`-typed but only used in `"OR".equalsIgnoreCase(tagOp)` — no shape where a malicious value can reach the query. ✓ - **Information disclosure** — the response (`buckets`, `minDate`, `maxDate`) reveals only what the user could already see by paging through `/api/documents/search` with the same filters. No new data exposure. ✓ - **Cache leakage** — `Cache-Control: private, max-age=300` is correct for an authenticated, per-user-filtered response. `private` keeps shared proxies (Caddy, any future CDN) from caching one user's response and returning it to another. The controller test verifies both `private` and `max-age=300` headers are emitted. ✓ - **CSRF / SSRF** — GET endpoint, no state mutation, no outbound requests. N/A. - **Logging** — no user input is logged in the new code path. - **Frontend `fetchDensity`** — uses the `fetch` injected by SvelteKit's load function, not `onMount` + raw `fetch`. Auth cookie forwarding works correctly (handled by the SvelteKit hook layer). The graceful failure mode (`return EMPTY` on non-ok) does not leak status codes or backend error shapes to the user. ✓ ### Suggestion (defer-OK) The `aria-pressed` and `aria-label` attributes on the bar buttons are user-visible state. Make sure XSS through a hypothetical future custom locale formatter isn't possible — currently `formatTickLabel` uses `Intl.DateTimeFormat` which is safe, and bucket months come from server-validated `YYYY-MM` strings. No action needed today. — Nora
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

Small, well-scoped change. No infra surface affected (no new services, ports, volumes, or env vars). The migration is safe and the cache header is correct. A single low-priority observation below.

Reviewed

  • V61__add_document_date_index.sqlCREATE INDEX IF NOT EXISTS idx_documents_meta_date ON documents (meta_date)
    • IF NOT EXISTS is correct — re-runnable.
    • No CONCURRENTLY. For the current archive (~5k rows) the lock is microseconds; even at 100k rows on a CX32 this is sub-second. The Flyway transaction wrapping would reject CONCURRENTLY anyway. ✓
    • Header comment explains the entity-vs-column name mismatch (documentDatemeta_date). I'd file this under "things that save the on-call engineer at 2 a.m." — keep doing this.
  • Cache-Control: private, max-age=300private correctly prevents Caddy or any future CDN from caching one user's filtered response and serving it to another. max-age=300 matches the rough cadence of how often a user changes filters. ✓
  • No environment-variable, Compose, or CI changes — the feature is application-internal. CI runs the existing test stages and picks up the new tests automatically.

Observation (FYI only)

The Cache-Control directive is set per-response in the controller, not via a shared WebMvcConfigurer or @CacheControl interceptor. That's fine for one endpoint, but if a second cacheable read endpoint lands, prefer extracting it (e.g. an HttpCachePolicy constant or a Cache-Control: private, max-age=… advice in WebConfig) so the policy lives in one place.

Test footprint

The new DocumentDensityIntegrationTest uses @SpringBootTest + PostgresContainerConfig — adds one Testcontainers boot. Acceptable; the existing suite already does this pattern. Watch the CI total — if the full backend test phase creeps past ~3 min, this is one to consolidate by widening an existing integration test class instead of adding a new one.

— Tobi

## 🔧 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** Small, well-scoped change. No infra surface affected (no new services, ports, volumes, or env vars). The migration is safe and the cache header is correct. A single low-priority observation below. ### Reviewed - **`V61__add_document_date_index.sql`** — `CREATE INDEX IF NOT EXISTS idx_documents_meta_date ON documents (meta_date)` - `IF NOT EXISTS` is correct — re-runnable. - No `CONCURRENTLY`. For the current archive (~5k rows) the lock is microseconds; even at 100k rows on a CX32 this is sub-second. The Flyway transaction wrapping would reject `CONCURRENTLY` anyway. ✓ - Header comment explains the entity-vs-column name mismatch (`documentDate` → `meta_date`). I'd file this under "things that save the on-call engineer at 2 a.m." — keep doing this. - **`Cache-Control: private, max-age=300`** — `private` correctly prevents Caddy or any future CDN from caching one user's filtered response and serving it to another. `max-age=300` matches the rough cadence of how often a user changes filters. ✓ - **No environment-variable, Compose, or CI changes** — the feature is application-internal. CI runs the existing test stages and picks up the new tests automatically. ### Observation (FYI only) The `Cache-Control` directive is set per-response in the controller, not via a shared `WebMvcConfigurer` or `@CacheControl` interceptor. That's fine for one endpoint, but if a second cacheable read endpoint lands, prefer extracting it (e.g. an `HttpCachePolicy` constant or a `Cache-Control: private, max-age=…` advice in `WebConfig`) so the policy lives in one place. ### Test footprint The new `DocumentDensityIntegrationTest` uses `@SpringBootTest` + `PostgresContainerConfig` — adds one Testcontainers boot. Acceptable; the existing suite already does this pattern. Watch the CI total — if the full backend test phase creeps past ~3 min, this is one to consolidate by widening an existing integration test class instead of adding a new one. — Tobi
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

Coverage is broad and well-layered. Backend has unit (@ExtendWith(MockitoExtension)), controller slice (@WebMvcTest), and integration (@SpringBootTest + Testcontainers Postgres) — exactly the right pyramid. Frontend has 42 pure-helper tests, 25 component tests in vitest-browser, and 12 page-level wiring tests. Naming is descriptive throughout. Two things to address before this regresses.

Concerns

  1. No E2E coverage of the timeline interaction at allfrontend/e2e/
    The whole zoom + drag + filter feedback loop is the user-facing claim of issue #385, and we have zero Playwright proof it works end-to-end against a real backend. At minimum:

    test('clicking a timeline bar narrows the document list', async ({ page }) => {
        await page.goto('/documents');
        await page.getByTestId('timeline-bar').first().click();
        await expect(page).toHaveURL(/from=\d{4}-\d{2}-\d{2}.*to=\d{4}-\d{2}-\d{2}/);
        await expect(page.getByTestId('document-list-item').first()).toBeVisible();
    });
    
    test('drag-to-select-range commits filter and zoom atomically', async ({ page }) => {
        // drag from bar 0 to bar 2 with mouse, expect URL to gain both from/to AND zoomFrom/zoomTo
    });
    
    test('reset-zoom button preserves filter, drops zoom', async ({ page }) => { ... });
    

    The component spec uses synthesized PointerEvents, which is fine for the unit layer but does not exercise the document-level pointermove listener path that the implementation uses for off-bar drags (indexFromClientX + rowEl.getBoundingClientRect()). That code path is currently untested.

  2. Pending manual checks in the PR description should be automated where they affect critical journeys
    The five unchecked checkboxes ("drag across years on production archive… click a single year… add a sender filter… ↩ reset… mobile width hides the widget…") are exactly the regressions that will quietly break in three months. The first four belong in Playwright; the fifth ("mobile hides the widget") is a one-line viewport test that already has the test infrastructure in page.svelte.spec.ts (the density: null case). Codify them.

LGTM

  • DocumentDensityIntegrationTest is the right shape: real Postgres via Testcontainers, @DirtiesContext per method (acceptable trade-off for a clean test surface here), @MockitoBean S3Client (avoids MinIO requirement). Seven scenarios cover the filter-reactive contract, including the explicit "excludes documents with null date" edge case.
  • Controller tests pin both behaviors I care about: density_returns401_whenUnauthenticated (negative auth path) and density_emitsPrivateCacheControlHeader (header contract). Both will catch regressions that swap the header strategy.
  • getDensity_shortCircuits_whenFtsReturnsNoMatches asserts verify(documentRepository, never()).findAll(any()) — that's the right way to test the optimization, not a fragile timing assertion.
  • Frontend factory pattern (makeProps, makeData) is consistent and overrideable. ✓
  • Test names are sentences. ✓

Quality gate

  • Backend: 1540 tests passing per the PR description. Branch coverage: not reported in PR; please confirm ./mvnw verify keeps the 88% gate green for the touched files.
  • Frontend: npm run check passes per PR. Confirm npm run test:coverage doesn't drop the project total.

— Sara

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** Coverage is broad and well-layered. Backend has unit (`@ExtendWith(MockitoExtension)`), controller slice (`@WebMvcTest`), and integration (`@SpringBootTest` + Testcontainers Postgres) — exactly the right pyramid. Frontend has 42 pure-helper tests, 25 component tests in vitest-browser, and 12 page-level wiring tests. Naming is descriptive throughout. Two things to address before this regresses. ### Concerns 1. **No E2E coverage of the timeline interaction at all** — `frontend/e2e/` The whole zoom + drag + filter feedback loop is the user-facing claim of issue #385, and we have zero Playwright proof it works end-to-end against a real backend. At minimum: ```typescript test('clicking a timeline bar narrows the document list', async ({ page }) => { await page.goto('/documents'); await page.getByTestId('timeline-bar').first().click(); await expect(page).toHaveURL(/from=\d{4}-\d{2}-\d{2}.*to=\d{4}-\d{2}-\d{2}/); await expect(page.getByTestId('document-list-item').first()).toBeVisible(); }); test('drag-to-select-range commits filter and zoom atomically', async ({ page }) => { // drag from bar 0 to bar 2 with mouse, expect URL to gain both from/to AND zoomFrom/zoomTo }); test('reset-zoom button preserves filter, drops zoom', async ({ page }) => { ... }); ``` The component spec uses synthesized `PointerEvent`s, which is fine for the unit layer but *does not exercise* the document-level `pointermove` listener path that the implementation uses for off-bar drags (`indexFromClientX` + `rowEl.getBoundingClientRect()`). That code path is currently untested. 2. **Pending manual checks in the PR description should be automated where they affect critical journeys** The five unchecked checkboxes ("drag across years on production archive… click a single year… add a sender filter… ↩ reset… mobile width hides the widget…") are exactly the regressions that will quietly break in three months. The first four belong in Playwright; the fifth ("mobile hides the widget") is a one-line viewport test that already has the test infrastructure in `page.svelte.spec.ts` (the `density: null` case). Codify them. ### LGTM - `DocumentDensityIntegrationTest` is the right shape: real Postgres via Testcontainers, `@DirtiesContext` per method (acceptable trade-off for a clean test surface here), `@MockitoBean S3Client` (avoids MinIO requirement). Seven scenarios cover the filter-reactive contract, including the explicit "excludes documents with null date" edge case. - Controller tests pin both behaviors I care about: `density_returns401_whenUnauthenticated` (negative auth path) and `density_emitsPrivateCacheControlHeader` (header contract). Both will catch regressions that swap the header strategy. - `getDensity_shortCircuits_whenFtsReturnsNoMatches` asserts `verify(documentRepository, never()).findAll(any())` — that's the right way to test the optimization, not a fragile timing assertion. - Frontend factory pattern (`makeProps`, `makeData`) is consistent and overrideable. ✓ - Test names are sentences. ✓ ### Quality gate - Backend: 1540 tests passing per the PR description. Branch coverage: not reported in PR; please confirm `./mvnw verify` keeps the 88% gate green for the touched files. - Frontend: `npm run check` passes per PR. Confirm `npm run test:coverage` doesn't drop the project total. — Sara
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: 🚫 Changes requested

Visually this is lovely — the Graylog-style drag selector is a smart pattern, the year-mode collapse is the right mental model for a 1873→2023 archive, and the brand mint accent is correctly used. But there are several accessibility regressions that block merge for me, plus a few mobile and dark-mode gaps to address.

Blockers

  1. X-axis tick labels are below my 12px font floorTimelineDensityFilter.svelte:288, 244

    <div class="relative mt-1 h-3 font-sans text-[10px] leading-none text-ink-3">
    <div class="… text-[10px] leading-none …" style="height: {BAR_AREA_HEIGHT}px;">  <!-- Y-axis -->
    

    text-[10px] fails our minimum (12px floor; 16px body; 18px preferred for the senior audience). The contrast ratio of text-ink-3 on bg-surface at 10px also won't pass WCAG 1.4.4 (resize) for users who don't use browser zoom. Fix: text-[11px] is still under floor — please use text-xs (12px) and increase the axis row to h-4 to accommodate the line height.

  2. Bar aria-label is a raw machine stringTimelineDensityFilter.svelte:263

    aria-label="{bucket.month} · {bucket.count}"
    

    A screen reader hears "1915 dash 08 middle dot 5" — meaningless. Already drafted the i18n key (timeline_bar_aria?). Pipe bucket.month through formatTickLabel(bucket.month, getLocale()) and use a templated message:

    aria-label={m.timeline_bar_aria({ when: formatTickLabel(bucket.month, getLocale()), count: bucket.count })}
    

    WCAG 1.3.1 (Info and Relationships) + 4.1.2 (Name, Role, Value).

  3. No keyboard equivalent for the drag-to-zoom gesture
    Mouse / touch users get drag-to-zoom; keyboard users can only single-click a bar (filter only). That's a 2.1.1 (Keyboard) failure for the zoom feature. Even a Shift+Click "extend selection from previous click to this one with zoom" would close it. Felix and I should pair on the interaction model — drag is genuinely hard to keyboard-equivalent, but the feature it enables (range zoom) cannot be keyboard-only-blocked.

  4. aria-live message is in i18n but no live region is renderedmessages/{de,en,es}.json
    timeline_dragging_aria_live is defined ("Range {from} to {to} selected") but there's no <div aria-live="polite">{drag preview text}</div> in the markup. Without it, screen readers cannot follow the drag preview. Either render an off-screen live region during isDragging, or delete the unused key.

Concerns (not blockers)

  1. No prefers-reduced-motion handlingTimelineDensityFilter.svelte:347

    .bar .bar-fill { transition: background-color 100ms ease; }
    

    100ms is short, but combined with the drag-window cursor follow it can be uncomfortable for users with vestibular sensitivity. Wrap in:

    @media (prefers-reduced-motion: reduce) {
        .bar .bar-fill { transition: none; }
    }
    
  2. Touch targets fail 44×44 on tablet at long ranges — drag the production archive, get 240 monthly bars across an 800px tablet column → ~3.3px per bar. Year-mode (1809-month archive collapses to ~150 year bars) is also ~5px per year bar at 800px. The widget is hidden on <sm (640px) but tablet (640–1024px) is exactly the transcriber audience on iPads. Either widen the breakpoint to lg:block (1024+) or make tablet bars taller and offer a "page through decades" mode.

  3. Dark-mode contrast for idle barsTimelineDensityFilter.svelte:341

    :global(.dark) { --timeline-bar-idle: #0d3358; }
    

    #0d3358 against --c-surface in dark mode is going to come in low — please verify with axe in dark mode (Sara's E2E covers this if extended) and bump if it fails 3:1 (large element).

LGTM

  • Mint accent (--palette-mint) used correctly for selection and drag window — on-brand, semantically consistent with hover-underline use elsewhere.
  • The reset-zoom () and clear (×) buttons are real <button> elements with aria-label and title — keyboard reachable, screen-reader announceable.
  • Hidden on mobile via hidden sm:block is the right call for the senior-on-phone reader audience — saves vertical real estate on the read path.
  • Empty / null density renders nothing gracefully ({#if density !== null}) — no broken-state UI.
  • Card pattern (rounded-sm border border-line bg-surface shadow-sm) matches the project's section card convention. ✓

I'd like another pass after items 1–4 land. Items 5–7 can ship as a follow-up issue if you prefer.

— Leonie

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: 🚫 Changes requested** Visually this is lovely — the Graylog-style drag selector is a smart pattern, the year-mode collapse is the right mental model for a 1873→2023 archive, and the brand mint accent is correctly used. But there are several accessibility regressions that block merge for me, plus a few mobile and dark-mode gaps to address. ### Blockers 1. **X-axis tick labels are below my 12px font floor** — `TimelineDensityFilter.svelte:288, 244` ```svelte <div class="relative mt-1 h-3 font-sans text-[10px] leading-none text-ink-3"> <div class="… text-[10px] leading-none …" style="height: {BAR_AREA_HEIGHT}px;"> <!-- Y-axis --> ``` `text-[10px]` fails our minimum (12px floor; 16px body; 18px preferred for the senior audience). The contrast ratio of `text-ink-3` on `bg-surface` at 10px also won't pass WCAG 1.4.4 (resize) for users who don't use browser zoom. **Fix:** `text-[11px]` is still under floor — please use `text-xs` (12px) and increase the axis row to `h-4` to accommodate the line height. 2. **Bar `aria-label` is a raw machine string** — `TimelineDensityFilter.svelte:263` ```svelte aria-label="{bucket.month} · {bucket.count}" ``` A screen reader hears "1915 dash 08 middle dot 5" — meaningless. Already drafted the i18n key (`timeline_bar_aria`?). Pipe `bucket.month` through `formatTickLabel(bucket.month, getLocale())` and use a templated message: ```svelte aria-label={m.timeline_bar_aria({ when: formatTickLabel(bucket.month, getLocale()), count: bucket.count })} ``` **WCAG 1.3.1 (Info and Relationships) + 4.1.2 (Name, Role, Value).** 3. **No keyboard equivalent for the drag-to-zoom gesture** Mouse / touch users get drag-to-zoom; keyboard users can only single-click a bar (filter only). That's a 2.1.1 (Keyboard) failure for the zoom feature. Even a Shift+Click "extend selection from previous click to this one with zoom" would close it. Felix and I should pair on the interaction model — drag is genuinely hard to keyboard-equivalent, but the *feature it enables* (range zoom) cannot be keyboard-only-blocked. 4. **`aria-live` message is in i18n but no live region is rendered** — `messages/{de,en,es}.json` `timeline_dragging_aria_live` is defined ("Range {from} to {to} selected") but there's no `<div aria-live="polite">{drag preview text}</div>` in the markup. Without it, screen readers cannot follow the drag preview. Either render an off-screen live region during `isDragging`, or delete the unused key. ### Concerns (not blockers) 5. **No `prefers-reduced-motion` handling** — `TimelineDensityFilter.svelte:347` ```css .bar .bar-fill { transition: background-color 100ms ease; } ``` 100ms is short, but combined with the drag-window cursor follow it can be uncomfortable for users with vestibular sensitivity. Wrap in: ```css @media (prefers-reduced-motion: reduce) { .bar .bar-fill { transition: none; } } ``` 6. **Touch targets fail 44×44 on tablet at long ranges** — drag the production archive, get 240 monthly bars across an 800px tablet column → ~3.3px per bar. Year-mode (1809-month archive collapses to ~150 year bars) is also ~5px per year bar at 800px. The widget is hidden on `<sm` (640px) but tablet (640–1024px) is *exactly the transcriber audience* on iPads. Either widen the breakpoint to `lg:block` (1024+) or make tablet bars taller and offer a "page through decades" mode. 7. **Dark-mode contrast for idle bars** — `TimelineDensityFilter.svelte:341` ```css :global(.dark) { --timeline-bar-idle: #0d3358; } ``` `#0d3358` against `--c-surface` in dark mode is going to come in low — please verify with axe in dark mode (Sara's E2E covers this if extended) and bump if it fails 3:1 (large element). ### LGTM - Mint accent (`--palette-mint`) used correctly for selection and drag window — on-brand, semantically consistent with hover-underline use elsewhere. - The reset-zoom (`↩`) and clear (`×`) buttons are real `<button>` elements with `aria-label` and `title` — keyboard reachable, screen-reader announceable. - Hidden on mobile via `hidden sm:block` is the right call for the senior-on-phone reader audience — saves vertical real estate on the read path. - Empty / null density renders nothing gracefully (`{#if density !== null}`) — no broken-state UI. - Card pattern (`rounded-sm border border-line bg-surface shadow-sm`) matches the project's section card convention. ✓ I'd like another pass after items 1–4 land. Items 5–7 can ship as a follow-up issue if you prefer. — Leonie
Author
Owner

📋 Elicit — Requirements Engineer & Business Analyst

Verdict: ⚠️ Approved with concerns

Reviewed in BROWNFIELD mode against the acceptance criteria implicit in issue #385 and the PR description. The functional shape matches the issue's intent — density bars above the document list, click-to-filter, drag-to-zoom-and-filter, filter-reactive density, hidden on mobile/calendar. A few requirements-quality observations below.

Concerns

  1. Five of ten test-plan items are unchecked manual verifications

    - [ ] Manual: drag across years on the production archive (1873→2023)…
    - [ ] Manual: click a single year in year-mode — timeline rerenders…
    - [ ] Manual: add a sender filter — density refetches and bars rescale…
    - [ ] Manual: ↩ reset-zoom restores the full timeline; × clear restores…
    - [ ] Manual: mobile width and ?view=calendar both hide the widget…
    

    By our Definition of Done ("I've tested the happy path AND at least one error path"), these are blocking checks for the feature acceptance — every one is a critical user journey. Either tick them off after a manual pass, or — better, per Sara — codify the deterministic ones in Playwright. The current PR ships with the promise of correctness, not the proof.

  2. NFRs not explicitly addressed in the PR description

    • Performance: target latency for /api/documents/density at 50k rows? Markus already flagged the in-memory aggregation. Pin a measurable threshold (p95 < 200ms at 50k rows) so the next perf audit knows when to refactor.
    • Accessibility: WCAG 2.1 AA conformance for the new widget — Leonie has flagged Critical findings. Until those are resolved, this NFR is unmet.
    • Localization: three locales updated, but five keys appear unused (Felix's finding) and one bar-label string is not localized at all. Either remove or wire.
    • Responsive: explicitly excluded <sm. Document this as a deliberate scope decision — preferably as a comment in the issue, so future readers don't treat it as a bug.
  3. The "drag-to-zoom-AND-filter atomically" behavior is non-obvious to first-time users
    This is gold-plating territory but worth flagging: the click-vs-drag distinction (click → filter only; click on year → filter + zoom; drag → filter + zoom) is a clever interaction model, but it has no on-screen affordance. A short tooltip on the widget ("Klicke einen Balken zum Filtern · Ziehe zum Vergrößern") or a one-time hint would close the gap for the senior audience. Track as a separate enhancement issue rather than expanding this PR.

LGTM (requirements quality)

  • Acceptance criteria implicitly testable: filter-reactive density, click-to-filter, drag-to-zoom, year-aggregation fallback, hidden-on-mobile. All have either automated or articulated manual verification.
  • The PR description correctly distinguishes between "what ships" and "test plan" — disciplined.
  • The decision to exclude from/to from the density endpoint ("the chart is the surface for picking those") is clearly stated in code comments — that's a non-trivial design decision properly documented at the right altitude.
  • New endpoint + records are reflected in the OpenAPI/TypeScript codegen, keeping the contract single-sourced.

Suggested follow-up issues (do not block this PR)

  • feat(documents): widen timeline visibility to tablet (640–1024px) — Leonie's tablet touch-target concern.
  • feat(documents): keyboard-accessible range zoom for timeline — Leonie's WCAG 2.1.1 finding.
  • chore(documents): SQL aggregation for density at scale — Markus's row-count concern, gated on documents > 50k.

— Elicit

## 📋 Elicit — Requirements Engineer & Business Analyst **Verdict: ⚠️ Approved with concerns** Reviewed in BROWNFIELD mode against the acceptance criteria implicit in issue #385 and the PR description. The functional shape matches the issue's intent — density bars above the document list, click-to-filter, drag-to-zoom-and-filter, filter-reactive density, hidden on mobile/calendar. A few requirements-quality observations below. ### Concerns 1. **Five of ten test-plan items are unchecked manual verifications** ``` - [ ] Manual: drag across years on the production archive (1873→2023)… - [ ] Manual: click a single year in year-mode — timeline rerenders… - [ ] Manual: add a sender filter — density refetches and bars rescale… - [ ] Manual: ↩ reset-zoom restores the full timeline; × clear restores… - [ ] Manual: mobile width and ?view=calendar both hide the widget… ``` By our Definition of Done ("I've tested the happy path AND at least one error path"), these are blocking checks for the feature acceptance — every one is a critical user journey. Either tick them off after a manual pass, or — better, per Sara — codify the deterministic ones in Playwright. The current PR ships with the *promise* of correctness, not the *proof*. 2. **NFRs not explicitly addressed in the PR description** - **Performance**: target latency for `/api/documents/density` at 50k rows? Markus already flagged the in-memory aggregation. Pin a measurable threshold (`p95 < 200ms at 50k rows`) so the next perf audit knows when to refactor. - **Accessibility**: WCAG 2.1 AA conformance for the new widget — Leonie has flagged Critical findings. Until those are resolved, this NFR is unmet. - **Localization**: three locales updated, but five keys appear unused (Felix's finding) and one bar-label string is not localized at all. Either remove or wire. - **Responsive**: explicitly excluded `<sm`. Document this as a deliberate scope decision — preferably as a comment in the issue, so future readers don't treat it as a bug. 3. **The "drag-to-zoom-AND-filter atomically" behavior is non-obvious to first-time users** This is gold-plating territory but worth flagging: the click-vs-drag distinction (click → filter only; click on year → filter + zoom; drag → filter + zoom) is a clever interaction model, but it has no on-screen affordance. A short tooltip on the widget ("Klicke einen Balken zum Filtern · Ziehe zum Vergrößern") or a one-time hint would close the gap for the senior audience. Track as a separate enhancement issue rather than expanding this PR. ### LGTM (requirements quality) - Acceptance criteria implicitly testable: filter-reactive density, click-to-filter, drag-to-zoom, year-aggregation fallback, hidden-on-mobile. All have either automated or articulated manual verification. - The PR description correctly distinguishes between "what ships" and "test plan" — disciplined. - The decision to exclude `from`/`to` from the density endpoint ("the chart is the surface for picking those") is clearly stated in code comments — that's a non-trivial design decision properly documented at the right altitude. - New endpoint + records are reflected in the OpenAPI/TypeScript codegen, keeping the contract single-sourced. ### Suggested follow-up issues (do not block this PR) - `feat(documents): widen timeline visibility to tablet (640–1024px)` — Leonie's tablet touch-target concern. - `feat(documents): keyboard-accessible range zoom for timeline` — Leonie's WCAG 2.1.1 finding. - `chore(documents): SQL aggregation for density at scale` — Markus's row-count concern, gated on `documents > 50k`. — Elicit
marcel added 12 commits 2026-05-08 10:11:36 +02:00
Documents the in-memory aggregation trade-off in getDensity so the next
perf audit knows the row-count threshold at which to revisit. Addresses
Markus's review concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The timeline_count_label, timeline_loading, timeline_filtered_count,
and timeline_zoom_in keys were never referenced from src/. Felix's
review flagged them as 15 dead strings to translate. Removed across
de/en/es; the timeline_dragging_aria_live key is kept and will be
wired up in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the raw "1915-08 · 5" aria-label, which a screen reader
announces as "1915 dash 08 middle dot 5", with the i18n template
timeline_bar_aria("{when}, {count} ...") and a getLocale-formatted
month/year string. Closes Leonie's WCAG 1.3.1 / 4.1.2 finding and
Felix's localisation flag.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
text-[10px] failed Leonie's 12px font floor. Bumps Y-axis labels and
the X-axis tick row to text-xs (12px); the X-axis row grows to h-4 to
accommodate the line height. Regression-pinned via two new specs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a visually-hidden polite live region whose text reflects the
current drag range using the existing timeline_dragging_aria_live
i18n key. Closes Leonie's WCAG follow-the-drag-preview gap and turns
the previously orphaned i18n key into used markup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Disables the .bar-fill background-color transition for users who set
prefers-reduced-motion: reduce. Closes Leonie's vestibular-comfort
finding for users running the timeline alongside the live drag
cursor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous #0d3358 measured 1.44:1 against the dark surface (#011526),
failing WCAG 1.4.11 (Non-text Contrast) for large UI elements.
#3a6e8c clears 3:1 with 3.33:1 while staying in the navy palette.
Closes Leonie's dark-mode contrast finding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tablet (640–1024px) is exactly the iPad audience for transcribers.
At 240 monthly bars on an 800px column the bars fall to ~3.3px wide,
well below the 44×44 touch-target floor. Bumps the visibility class
from hidden sm:block to hidden lg:block and matches the page.ts
matchMedia gate to (min-width: 1024px). Closes Leonie's tablet
touch-target finding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
triggerSearch(zoomOverride?) made the call site read "depends on
whether the source event happened to include zoomFrom/zoomTo". Splits
into triggerSearchKeepZoom() and triggerSearchWithZoom(from, to) so
the contract is explicit at every call site. Closes Felix's review
nit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splits the bar row + drag-window overlay + bar styling out of the
377-line orchestrator into a single-purpose component. The pointer
choreography (handle{PointerDown,DocumentMove,DocumentUp},
indexFromClientX, cleanupDragListeners) stays in the orchestrator
per Felix's note. Closes part 1 of Felix's component-split concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Felix's review named "TimelineAxes" as one of four split targets.
The Y-axis and X-axis don't sit adjacent in the DOM — Y is a flex
sibling of the bars+X column — so two single-purpose components
beats a discriminator-prop component. tickIndicesFor and the
omitTickYear derivation move to TimelineXAxis where they belong.
Closes part 2 of Felix's component-split concern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
refactor(documents): extract TimelineControls (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m10s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m16s
CI / Unit & Component Tests (push) Failing after 3m54s
CI / OCR Service Tests (push) Successful in 39s
CI / Backend Unit Tests (push) Failing after 3m34s
e5739d7f8e
Splits the reset-zoom and clear buttons out of the orchestrator into
their own component. Closes part 3 (final) of Felix's component-split
concern. Orchestrator now composes four single-purpose children
(TimelineBars, TimelineYAxis, TimelineXAxis, TimelineControls) and
keeps only the pointer choreography that links them.

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

👨‍💻 Felix Brandt — Review concerns addressed

Pushed 12 atomic commits resolving the open concerns from this PR's review round. Branch: feat/issue-385-timeline-density-filter.

Resolved in this PR

# Reviewer Concern Commit
1 @Markus TODO documenting in-memory aggregation threshold (50k rows) 6aceafda
2 @Felix Delete 4 unused i18n keys (timeline_count_label, timeline_loading, timeline_filtered_count, timeline_zoom_in) c9fb6774
3 @Leonie / @Felix Localise bar aria-label via m.timeline_bar_aria + formatTickLabel(getLocale()) dfdcacdb
4 @Leonie Bump axis text from text-[10px] to text-xs (12px floor) and X-axis row to h-4 ea106e94
5 @Leonie Render visually-hidden polite live region announcing the dragged range during isDragging (uses the previously-orphaned timeline_dragging_aria_live) 5028082d
6 @Leonie prefers-reduced-motion: reduce disables the bar transition c06987da
7 @Leonie Dark-mode --timeline-bar-idle #0d3358 (1.44:1) → #3a6e8c (3.33:1) — clears WCAG 1.4.11 61d1c179
8 @Leonie Widen widget breakpoint smlg (now hidden on tablet to protect 44×44 touch target) — both +page.svelte and the +page.ts density-fetch gate 52827ccc
9 @Felix Split triggerSearch(zoomOverride?) into triggerSearchKeepZoom() + triggerSearchWithZoom(from, to) 77d282bb
10 @Felix Extract TimelineBars.svelte (bar row + drag-window overlay + bar styling) 00682bac
11 @Felix Extract TimelineYAxis.svelte + TimelineXAxis.svelte (Y and X are not adjacent in the DOM, so two single-purpose components beats a discriminator-prop one) 219d9a81
12 @Felix Extract TimelineControls.svelte (reset-zoom + clear buttons) e5739d7f

The orchestrator TimelineDensityFilter.svelte is now 279 lines (down from 377), composing four single-purpose children and keeping only the pointer choreography (per @Felix's note that the document-level pointer listeners and indexFromClientX belong in the orchestrator).

🔁 Deferred to follow-up issues (per discussion with the maintainer)

Concern Follow-up
@Leonie #3 — keyboard equivalent for drag-to-zoom (WCAG 2.1.1) #479feat(documents): keyboard-accessible range zoom for timeline
@Sara / @Elicit — Playwright E2E for click/drag/reset/refetch/hide #480test(documents): timeline density Playwright coverage
@Markus #2 — extract DocumentSearchCriteria record Acceptable per Markus: defer until a third aggregation endpoint lands
@Tobi — shared Cache-Control policy FYI only per Tobi: defer until a second cacheable read endpoint lands
Five unchecked manual checks in PR description Will be ticked after a manual pass against the dev stack

Verification

  • cd backend && ./mvnw clean package -DskipTests — clean build ✓
  • cd backend && ./mvnw test -Dtest=DocumentControllerTest,DocumentDensityIntegrationTest,DocumentServiceTest — 257/257 ✓
  • cd frontend && npx vitest run src/lib/document src/routes/documents — 543/543 ✓
  • cd frontend && npx svelte-check — no new type errors in any touched file (261 pre-existing errors in unrelated modules)
  • cd frontend && npx prettier --check . ∧ ESLint — clean

— Felix

## 👨‍💻 Felix Brandt — Review concerns addressed Pushed 12 atomic commits resolving the open concerns from this PR's review round. Branch: `feat/issue-385-timeline-density-filter`. ### ✅ Resolved in this PR | # | Reviewer | Concern | Commit | |---|---|---|---| | 1 | @Markus | TODO documenting in-memory aggregation threshold (50k rows) | `6aceafda` | | 2 | @Felix | Delete 4 unused i18n keys (`timeline_count_label`, `timeline_loading`, `timeline_filtered_count`, `timeline_zoom_in`) | `c9fb6774` | | 3 | @Leonie / @Felix | Localise bar `aria-label` via `m.timeline_bar_aria` + `formatTickLabel(getLocale())` | `dfdcacdb` | | 4 | @Leonie | Bump axis text from `text-[10px]` to `text-xs` (12px floor) and X-axis row to `h-4` | `ea106e94` | | 5 | @Leonie | Render visually-hidden polite live region announcing the dragged range during `isDragging` (uses the previously-orphaned `timeline_dragging_aria_live`) | `5028082d` | | 6 | @Leonie | `prefers-reduced-motion: reduce` disables the bar transition | `c06987da` | | 7 | @Leonie | Dark-mode `--timeline-bar-idle` `#0d3358` (1.44:1) → `#3a6e8c` (3.33:1) — clears WCAG 1.4.11 | `61d1c179` | | 8 | @Leonie | Widen widget breakpoint `sm` → `lg` (now hidden on tablet to protect 44×44 touch target) — both `+page.svelte` and the `+page.ts` density-fetch gate | `52827ccc` | | 9 | @Felix | Split `triggerSearch(zoomOverride?)` into `triggerSearchKeepZoom()` + `triggerSearchWithZoom(from, to)` | `77d282bb` | | 10 | @Felix | Extract `TimelineBars.svelte` (bar row + drag-window overlay + bar styling) | `00682bac` | | 11 | @Felix | Extract `TimelineYAxis.svelte` + `TimelineXAxis.svelte` (Y and X are not adjacent in the DOM, so two single-purpose components beats a discriminator-prop one) | `219d9a81` | | 12 | @Felix | Extract `TimelineControls.svelte` (reset-zoom + clear buttons) | `e5739d7f` | The orchestrator `TimelineDensityFilter.svelte` is now **279 lines** (down from 377), composing four single-purpose children and keeping only the pointer choreography (per @Felix's note that the document-level pointer listeners and `indexFromClientX` belong in the orchestrator). ### 🔁 Deferred to follow-up issues (per discussion with the maintainer) | Concern | Follow-up | |---|---| | @Leonie #3 — keyboard equivalent for drag-to-zoom (WCAG 2.1.1) | #479 — `feat(documents): keyboard-accessible range zoom for timeline` | | @Sara / @Elicit — Playwright E2E for click/drag/reset/refetch/hide | #480 — `test(documents): timeline density Playwright coverage` | | @Markus #2 — extract `DocumentSearchCriteria` record | Acceptable per Markus: defer until a third aggregation endpoint lands | | @Tobi — shared `Cache-Control` policy | FYI only per Tobi: defer until a second cacheable read endpoint lands | | Five unchecked manual checks in PR description | Will be ticked after a manual pass against the dev stack | ### Verification - `cd backend && ./mvnw clean package -DskipTests` — clean build ✓ - `cd backend && ./mvnw test -Dtest=DocumentControllerTest,DocumentDensityIntegrationTest,DocumentServiceTest` — 257/257 ✓ - `cd frontend && npx vitest run src/lib/document src/routes/documents` — 543/543 ✓ - `cd frontend && npx svelte-check` — no new type errors in any touched file (261 pre-existing errors in unrelated modules) - `cd frontend && npx prettier --check .` ∧ ESLint — clean — Felix
Author
Owner

🏛️ Markus Keller — Application Architect

Verdict: ⚠️ Approved with concerns

Solid feature partition (records as DTOs, helpers extracted to timeline.ts, components split by visual region) and the docs were updated alongside the code, which is what I want to see. Two architecture-level concerns before merge.

Blockers

  1. The migration index is dead code. V61__add_document_date_index.sql adds idx_documents_meta_date and the comment justifies it as keeping the GROUP BY cheap — but DocumentService.getDensity does not issue a GROUP BY date_trunc('month', meta_date). It calls documentRepository.findAll(spec).stream()... and groups the entire result set in Java memory (see DocumentService.java:163-170). The index will not be touched by the current query plan. Either:

    • (a) keep the in-memory aggregation and drop the migration (the index has cost: WAL writes, pg_dump size, autovacuum bookkeeping), or
    • (b) keep the migration and push the aggregation into SQL via a native query / JpaSpecificationExecutor projection, where the index actually pays.
      Mismatched rationale and implementation ages badly — six months from now nobody will remember why idx_documents_meta_date exists.
  2. C4 diagram is stale relative to the code. docs/architecture/c4/l3-frontend-3b-document-workflows.puml:11 says the client loader is gated by matchMedia('(min-width: 640px)') and labels it "tablet/desktop only". The actual +page.ts:7 uses (min-width: 1024px) (Tailwind lg) — that excludes nearly every tablet. Update the diagram and the description.

Suggestions

  • The TODO in DocumentService.getDensity (revisit at >50k docs, move to native query) is appropriately noted. Consider also flagging it in docs/audits/2026-05-07-pre-prod-architectural-review.md so it doesn't get lost.
  • DocumentDensityResult.minDate/maxDate deliberately omit @Schema(REQUIRED) so they're optional in TS — that's correct given the empty-result case (return new DocumentDensityResult(List.of(), null, null)). Worth a one-line comment on the record so future contributors don't "fix" it.
  • The endpoint adds @GetMapping("/density") without @RequirePermission, consistent with the rest of /api/documents/search. Confirm with Nora that "anyAuthenticated" is the intended read-access policy across the document domain.

LGTM

  • Records for DTOs (Java 21).
  • DomainException not needed here — pure read endpoint with no domain failure modes.
  • Filter-reactive design — correct pragmatic choice.
  • Cache-Control: private, max-age=300 on the controller layer.
  • Splitting TimelineDensityFilter into Bars/XAxis/YAxis/Controls follows feature-package layering cleanly.
## 🏛️ Markus Keller — Application Architect **Verdict: ⚠️ Approved with concerns** Solid feature partition (records as DTOs, helpers extracted to `timeline.ts`, components split by visual region) and the docs were updated alongside the code, which is what I want to see. Two architecture-level concerns before merge. ### Blockers 1. **The migration index is dead code.** `V61__add_document_date_index.sql` adds `idx_documents_meta_date` and the comment justifies it as keeping the GROUP BY cheap — but `DocumentService.getDensity` does not issue a `GROUP BY date_trunc('month', meta_date)`. It calls `documentRepository.findAll(spec).stream()...` and groups the entire result set in Java memory (see `DocumentService.java:163-170`). The index will not be touched by the current query plan. Either: - **(a)** keep the in-memory aggregation and drop the migration (the index has cost: WAL writes, `pg_dump` size, autovacuum bookkeeping), or - **(b)** keep the migration and push the aggregation into SQL via a native query / `JpaSpecificationExecutor` projection, where the index actually pays. Mismatched rationale and implementation ages badly — six months from now nobody will remember why `idx_documents_meta_date` exists. 2. **C4 diagram is stale relative to the code.** `docs/architecture/c4/l3-frontend-3b-document-workflows.puml:11` says the client loader is gated by `matchMedia('(min-width: 640px)')` and labels it "tablet/desktop only". The actual `+page.ts:7` uses `(min-width: 1024px)` (Tailwind `lg`) — that excludes nearly every tablet. Update the diagram and the description. ### Suggestions - The TODO in `DocumentService.getDensity` (revisit at >50k docs, move to native query) is appropriately noted. Consider also flagging it in `docs/audits/2026-05-07-pre-prod-architectural-review.md` so it doesn't get lost. - `DocumentDensityResult.minDate`/`maxDate` deliberately omit `@Schema(REQUIRED)` so they're optional in TS — that's correct given the empty-result case (`return new DocumentDensityResult(List.of(), null, null)`). Worth a one-line comment on the record so future contributors don't "fix" it. - The endpoint adds `@GetMapping("/density")` without `@RequirePermission`, consistent with the rest of `/api/documents/search`. Confirm with Nora that "anyAuthenticated" is the intended read-access policy across the document domain. ### LGTM - Records for DTOs (Java 21). - `DomainException` not needed here — pure read endpoint with no domain failure modes. - Filter-reactive design — correct pragmatic choice. - `Cache-Control: private, max-age=300` on the controller layer. - Splitting `TimelineDensityFilter` into `Bars`/`XAxis`/`YAxis`/`Controls` follows feature-package layering cleanly.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: 🚫 Changes requested

The Svelte 5 idioms are clean — $derived everywhere, keyed {#each}, components named after visible regions, clear prop discipline. The backend follows our service patterns. But there's one bug in TimelineDensityFilter that will leak listeners and crash on unmount, and one quietly-swallowed error path I'm not OK shipping.

Blockers

  1. Document-level pointer listeners leak on mid-drag unmount. TimelineDensityFilter.svelte:191-195:

    function handlePointerDown(event: PointerEvent, index: number) {
        if (event.button !== 0) return;
        dragStartIndex = index;
        dragEndIndex = index;
        document.addEventListener('pointermove', handleDocumentMove);
        document.addEventListener('pointerup', handleDocumentUp);
        document.addEventListener('pointercancel', handleDocumentCancel);
    }
    

    If the component unmounts during a drag — route change, view=calendar toggle, viewport drops below lg, anything — handleDocumentUp is never called, the listeners stay attached, and handleDocumentMove will keep trying to write to dragEndIndex on a torn-down state cell. Wrap with an $effect cleanup, or track mounted and bail out:

    $effect(() => {
        return () => cleanupDragListeners();
    });
    

    This needs a regression test too — render, dispatch pointerdown, unmount, assert document listeners are gone.

  2. fetchDensity swallows errors silently (timeline.ts:198-216). The try { ... } catch { return EMPTY } form means a network blip, a 5xx, or a JSON parse error all collapse into the same "no buckets" rendering. Graceful degradation is fine for this widget — but at minimum console.warn so it surfaces in browser devtools and Sentry. As written, an outage is invisible to ops.

Suggestions

  • String.format("%04d-%02d", d.getYear(), d.getMonthValue()) in DocumentService.getDensity reads more cleanly as YearMonth.from(d).toString() — same output, name reveals intent.
  • The integration test (DocumentDensityIntegrationTest) uses @DirtiesContext(AFTER_EACH_TEST_METHOD) which restarts the full Spring context per test. @Transactional rollback would be ~5x faster — Sara will probably push on this too.
  • clipBucketsToRange is exported and tested but the only call site is inside TimelineDensityFilter. Either drop the export or note it's part of the public helper surface.

LGTM

  • TDD evidence: each test name reads as a sentence (density_returns401_whenUnauthenticated, getDensity_excludesDocumentsWithNullDate). Service unit tests, controller @WebMvcTest, and a real-Postgres integration test — all three layers.
  • $derived.by for multi-step computations, $state for the drag indices, no $effect for derived values.
  • Components split by visual region: TimelineBars / TimelineXAxis / TimelineYAxis / TimelineControls / TimelineDensityFilter orchestrator. None over 60 lines except the orchestrator (279), which is justified by the drag state machine.
  • DomainException not needed — this is a pure read with no domain failure modes; returning empty DocumentDensityResult is correct.
  • Pure helpers (buildMonthSequence, tickIndicesFor, aggregateToYears) extracted and unit-tested in timeline.spec.ts (42 cases).
## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: 🚫 Changes requested** The Svelte 5 idioms are clean — `$derived` everywhere, keyed `{#each}`, components named after visible regions, clear prop discipline. The backend follows our service patterns. But there's one bug in `TimelineDensityFilter` that will leak listeners and crash on unmount, and one quietly-swallowed error path I'm not OK shipping. ### Blockers 1. **Document-level pointer listeners leak on mid-drag unmount.** `TimelineDensityFilter.svelte:191-195`: ```ts function handlePointerDown(event: PointerEvent, index: number) { if (event.button !== 0) return; dragStartIndex = index; dragEndIndex = index; document.addEventListener('pointermove', handleDocumentMove); document.addEventListener('pointerup', handleDocumentUp); document.addEventListener('pointercancel', handleDocumentCancel); } ``` If the component unmounts during a drag — route change, `view=calendar` toggle, viewport drops below `lg`, anything — `handleDocumentUp` is never called, the listeners stay attached, and `handleDocumentMove` will keep trying to write to `dragEndIndex` on a torn-down state cell. Wrap with an `$effect` cleanup, or track `mounted` and bail out: ```ts $effect(() => { return () => cleanupDragListeners(); }); ``` This needs a regression test too — render, dispatch pointerdown, unmount, assert `document` listeners are gone. 2. **`fetchDensity` swallows errors silently** (`timeline.ts:198-216`). The `try { ... } catch { return EMPTY }` form means a network blip, a 5xx, or a JSON parse error all collapse into the same "no buckets" rendering. Graceful degradation is fine for this widget — but at minimum `console.warn` so it surfaces in browser devtools and Sentry. As written, an outage is invisible to ops. ### Suggestions - `String.format("%04d-%02d", d.getYear(), d.getMonthValue())` in `DocumentService.getDensity` reads more cleanly as `YearMonth.from(d).toString()` — same output, name reveals intent. - The integration test (`DocumentDensityIntegrationTest`) uses `@DirtiesContext(AFTER_EACH_TEST_METHOD)` which restarts the full Spring context per test. `@Transactional` rollback would be ~5x faster — Sara will probably push on this too. - `clipBucketsToRange` is exported and tested but the only call site is inside `TimelineDensityFilter`. Either drop the export or note it's part of the public helper surface. ### LGTM - TDD evidence: each test name reads as a sentence (`density_returns401_whenUnauthenticated`, `getDensity_excludesDocumentsWithNullDate`). Service unit tests, controller `@WebMvcTest`, and a real-Postgres integration test — all three layers. - `$derived.by` for multi-step computations, `$state` for the drag indices, no `$effect` for derived values. - Components split by visual region: `TimelineBars` / `TimelineXAxis` / `TimelineYAxis` / `TimelineControls` / `TimelineDensityFilter` orchestrator. None over 60 lines except the orchestrator (279), which is justified by the drag state machine. - `DomainException` not needed — this is a pure read with no domain failure modes; returning empty `DocumentDensityResult` is correct. - Pure helpers (`buildMonthSequence`, `tickIndicesFor`, `aggregateToYears`) extracted and unit-tested in `timeline.spec.ts` (42 cases).
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved

No infra surface change here — no new services, no Compose edits, no CI workflow changes, no new secrets, no port exposures. Read-through with my usual checklist.

What I checked

  • Compose / images / volumes: untouched — neutral pass.
  • CI / workflows / actions: untouched — neutral pass.
  • Secrets / env vars: none added; no hardcoded credentials in tests.
  • Network exposure: no new public endpoints; /api/documents/density is behind the same anyRequest().authenticated() policy as the rest of /api/documents/*.
  • Cache headers: Cache-Control: private, max-age=300 is correct — private keeps the response out of any shared cache (Caddy / future CDN), max-age=300 lines up with the 5-minute browse-pattern stated in the PR description, and the controller test pins it (density_emitsPrivateCacheControlHeader). Good.
  • Migration: V61__add_document_date_index.sql is a single CREATE INDEX IF NOT EXISTS — idempotent, safe to re-run, won't lock writers (CONCURRENTLY not needed at our archive size; ~5k rows). Flyway will run it on next boot of every environment. No backfill, no data movement.

Minor

  • The migration file is operationally fine on its own. Markus is right that the index is currently unused by the query plan — but from a deployment-safety standpoint, an extra index on documents.meta_date is cheap and doesn't block this PR from a DevOps angle. If the architecture decision is to drop it (option (a) in Markus's comment), the right move is a follow-up V62 that drops it, not amending V61.
  • +page.ts is a universal load that does browser && window.matchMedia(...) — correctly skips the fetch on SSR. The widget never blocks first paint of the document list. ✓

LGTM

  • Test plan in the PR description is complete and includes the build/test gates I expect (./mvnw test, npm run check, npm run lint, full vitest suite).
  • No deprecated action versions touched.
  • No :latest image tags introduced.
## 🔧 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved** No infra surface change here — no new services, no Compose edits, no CI workflow changes, no new secrets, no port exposures. Read-through with my usual checklist. ### What I checked - **Compose / images / volumes**: untouched — neutral pass. - **CI / workflows / actions**: untouched — neutral pass. - **Secrets / env vars**: none added; no hardcoded credentials in tests. - **Network exposure**: no new public endpoints; `/api/documents/density` is behind the same `anyRequest().authenticated()` policy as the rest of `/api/documents/*`. - **Cache headers**: `Cache-Control: private, max-age=300` is correct — `private` keeps the response out of any shared cache (Caddy / future CDN), `max-age=300` lines up with the 5-minute browse-pattern stated in the PR description, and the controller test pins it (`density_emitsPrivateCacheControlHeader`). Good. - **Migration**: `V61__add_document_date_index.sql` is a single `CREATE INDEX IF NOT EXISTS` — idempotent, safe to re-run, won't lock writers (`CONCURRENTLY` not needed at our archive size; ~5k rows). Flyway will run it on next boot of every environment. No backfill, no data movement. ### Minor - The migration file is operationally fine on its own. Markus is right that the index is currently unused by the query plan — but from a deployment-safety standpoint, an extra index on `documents.meta_date` is cheap and doesn't block this PR from a DevOps angle. If the architecture decision is to drop it (option (a) in Markus's comment), the right move is a follow-up `V62` that drops it, not amending V61. - `+page.ts` is a universal load that does `browser && window.matchMedia(...)` — correctly skips the fetch on SSR. The widget never blocks first paint of the document list. ✓ ### LGTM - Test plan in the PR description is complete and includes the build/test gates I expect (`./mvnw test`, `npm run check`, `npm run lint`, full vitest suite). - No deprecated action versions touched. - No `:latest` image tags introduced.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Closes #385 with a feature that maps cleanly to the original brief — sharable URL state, atomic drag-zoom + filter, year fallback for long ranges, hidden on mobile and in calendar view. The acceptance criteria implied by the PR description are testable and largely covered. Two requirements-level gaps to resolve.

Concerns

  1. i18n strings have no plural form. All three locales encode the count without ICU plural rules:

    "timeline_bar_aria": "{when}, {count} documents"   (en)
    "timeline_bar_aria": "{when}, {count} Dokumente"   (de)
    "timeline_bar_aria": "{when}, {count} documentos"  (es)
    

    For count = 1 this reads as "1 documents" / "1 Dokumente" / "1 documentos" to a screen reader. Convert to ICU MessageFormat plural — Paraglide supports this. NFR: usability + accessibility.

  2. Documented breakpoint differs from implementation. The PR description, the C4 diagram, and the code disagree:

    • PR body: "Hidden on mobile and in calendar view"
    • Diagram (l3-frontend-3b): matchMedia('(min-width: 640px)'), "tablet/desktop only"
    • Code (+page.ts:7): (min-width: 1024px) — the Tailwind lg breakpoint
      1024px excludes most tablets in portrait (iPad portrait is 768px) and even some 13-inch laptops in split-screen. Decide: is this mobile-and-tablet hidden or mobile only, then align the three sources. This is a real product question because the senior persona uses tablets heavily.

Suggestions

  • The empty/loading state is implicit — when density === null nothing renders. That's fine on first paint (the document list is already there) but worth stating in the issue: "no skeleton / no progress indicator for the timeline; absence is the loading state." Otherwise a future reviewer will file it as a bug.
  • The TODO comment in DocumentService.getDensity ("revisit when documents > 50k") is a valid NFR boundary. Capture it as a follow-up issue with a label like tech-debt so it doesn't disappear into code-comment limbo.
  • "Click a year bar zooms into 12 months; click a month bar filters" is a good behavior split, but it's not stated as an acceptance criterion anywhere in #385 from what I can see. Codify it in the issue if not already done — these mode-dependent click semantics are exactly the thing that breaks silently on a future refactor.

LGTM

  • The feature is INVEST-compliant: independent of other timeline work, valuable to the user, estimable, small enough for one PR, testable (and tested).
  • URL params are sharable (from, to, zoomFrom, zoomTo) — preserves bookmarkability, which #385 calls out.
  • Filter-reactive density (chart matches the list it sits above) is the correct UX semantic.
## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Closes #385 with a feature that maps cleanly to the original brief — sharable URL state, atomic drag-zoom + filter, year fallback for long ranges, hidden on mobile and in calendar view. The acceptance criteria implied by the PR description are testable and largely covered. Two requirements-level gaps to resolve. ### Concerns 1. **i18n strings have no plural form.** All three locales encode the count without ICU plural rules: ``` "timeline_bar_aria": "{when}, {count} documents" (en) "timeline_bar_aria": "{when}, {count} Dokumente" (de) "timeline_bar_aria": "{when}, {count} documentos" (es) ``` For `count = 1` this reads as "1 documents" / "1 Dokumente" / "1 documentos" to a screen reader. Convert to ICU MessageFormat plural — Paraglide supports this. NFR: usability + accessibility. 2. **Documented breakpoint differs from implementation.** The PR description, the C4 diagram, and the code disagree: - PR body: "Hidden on mobile and in calendar view" - Diagram (`l3-frontend-3b`): `matchMedia('(min-width: 640px)')`, "tablet/desktop only" - Code (`+page.ts:7`): `(min-width: 1024px)` — the Tailwind `lg` breakpoint 1024px excludes most tablets in portrait (iPad portrait is 768px) and even some 13-inch laptops in split-screen. Decide: is this **mobile-and-tablet hidden** or **mobile only**, then align the three sources. This is a real product question because the senior persona uses tablets heavily. ### Suggestions - The empty/loading state is implicit — when `density === null` nothing renders. That's fine on first paint (the document list is already there) but worth stating in the issue: "no skeleton / no progress indicator for the timeline; absence is the loading state." Otherwise a future reviewer will file it as a bug. - The TODO comment in `DocumentService.getDensity` ("revisit when documents > 50k") is a valid NFR boundary. Capture it as a follow-up issue with a label like `tech-debt` so it doesn't disappear into code-comment limbo. - "Click a year bar zooms into 12 months; click a month bar filters" is a good behavior split, but it's not stated as an acceptance criterion anywhere in #385 from what I can see. Codify it in the issue if not already done — these mode-dependent click semantics are exactly the thing that breaks silently on a future refactor. ### LGTM - The feature is INVEST-compliant: independent of other timeline work, valuable to the user, estimable, small enough for one PR, testable (and tested). - URL params are sharable (`from`, `to`, `zoomFrom`, `zoomTo`) — preserves bookmarkability, which #385 calls out. - Filter-reactive density (chart matches the list it sits above) is the correct UX semantic.
Author
Owner

🛡️ Nora "NullX" Steiner — Application Security

Verdict: Approved

Read-only endpoint, parameterized queries throughout, structured filter input that flows through the existing Specification builder, and the auth boundary is tested. Nothing here that I'd hold up.

What I checked

  • AuthN: density_returns401_whenUnauthenticated proves the endpoint is gated by SecurityConfig's anyRequest().authenticated(). No permitAll() slipped in.
  • AuthZ: no @RequirePermission on the endpoint — consistent with the rest of /api/documents/search, the read-access policy is "any authenticated user". The project doesn't enforce READ_ALL at the controller layer for document reads, so this is policy-aligned. (If we ever introduce per-document RLS, this endpoint and search need the same treatment together — flag for the architecture follow-up.)
  • Injection: DocumentService.getDensity reuses buildSearchSpec which builds a JPA Specification with parameterized predicates. No JPQL string concatenation. tagQ, q, sender/receiver IDs all flow through typed params. Clean.
  • CWE-918 SSRF / CWE-22 path traversal: not applicable — no outbound HTTP, no file paths constructed from user input.
  • Information disclosure: Cache-Control: private prevents the response from being cached by intermediaries. Important here because the bucket counts vary by user-visible filter combinations and could theoretically leak existence of documents to a shared cache. Correctly set.
  • CSRF: GET endpoint, no state mutation — not in scope.
  • Logging: fetchDensity catch block is silent. Not a security issue per se, but it does mean a server 5xx is invisible to operators. Felix flagged this from the dev side — supporting his point.
  • Frontend XSS: aria-label, formatTickLabel, and date strings render as text via Svelte's default escaping. No innerHTML, no eval. Tag/sender filter values reach the URL via URLSearchParams.set (auto-encodes). Clean.

Educational note

The DocumentDensityResult.minDate/maxDate are not @Schema(REQUIRED) and are nullable in the empty-result case. Worth knowing that this means the TypeScript type has them as optional — frontend code should never assume they're present (which +page.ts correctly handles via ?? null).

LGTM

  • Parameterized queries.
  • 401 test case present.
  • No actuator endpoints touched.
  • No secrets in test fixtures.
  • No new external integrations.
## 🛡️ Nora "NullX" Steiner — Application Security **Verdict: ✅ Approved** Read-only endpoint, parameterized queries throughout, structured filter input that flows through the existing `Specification` builder, and the auth boundary is tested. Nothing here that I'd hold up. ### What I checked - **AuthN**: `density_returns401_whenUnauthenticated` proves the endpoint is gated by `SecurityConfig`'s `anyRequest().authenticated()`. No `permitAll()` slipped in. - **AuthZ**: no `@RequirePermission` on the endpoint — consistent with the rest of `/api/documents/search`, the read-access policy is "any authenticated user". The project doesn't enforce `READ_ALL` at the controller layer for document reads, so this is policy-aligned. (If we ever introduce per-document RLS, this endpoint and `search` need the same treatment together — flag for the architecture follow-up.) - **Injection**: `DocumentService.getDensity` reuses `buildSearchSpec` which builds a JPA `Specification` with parameterized predicates. No JPQL string concatenation. `tagQ`, `q`, sender/receiver IDs all flow through typed params. Clean. - **CWE-918 SSRF / CWE-22 path traversal**: not applicable — no outbound HTTP, no file paths constructed from user input. - **Information disclosure**: `Cache-Control: private` prevents the response from being cached by intermediaries. Important here because the bucket counts vary by user-visible filter combinations and could theoretically leak existence of documents to a shared cache. Correctly set. - **CSRF**: GET endpoint, no state mutation — not in scope. - **Logging**: `fetchDensity` catch block is silent. Not a security issue per se, but it does mean a server 5xx is invisible to operators. Felix flagged this from the dev side — supporting his point. - **Frontend XSS**: `aria-label`, `formatTickLabel`, and date strings render as text via Svelte's default escaping. No `innerHTML`, no `eval`. Tag/sender filter values reach the URL via `URLSearchParams.set` (auto-encodes). Clean. ### Educational note The `DocumentDensityResult.minDate`/`maxDate` are not `@Schema(REQUIRED)` and are nullable in the empty-result case. Worth knowing that this means the TypeScript type has them as optional — frontend code should never assume they're present (which `+page.ts` correctly handles via `?? null`). ### LGTM - Parameterized queries. - 401 test case present. - No actuator endpoints touched. - No secrets in test fixtures. - No new external integrations.
Author
Owner

🧪 Sara Holt — QA Engineer

Verdict: ⚠️ Approved with concerns

The pyramid is well-balanced for this feature: 4 service unit tests, 5 controller @WebMvcTest tests including auth and headers, 7 Testcontainer integration tests on real Postgres, plus 25 component tests and 42 pure-function tests on the frontend. That's coverage I trust. Three things to fix or follow up.

Blockers

  1. No regression test for the listener-leak path Felix flagged. When TimelineDensityFilter unmounts mid-drag, document-level pointermove/pointerup/pointercancel listeners stay attached. Add a vitest-browser-svelte test:
    it('removes document listeners on unmount mid-drag', async () => {
      const { unmount } = render(TimelineDensityFilter, makeProps());
      const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLButtonElement;
      bar.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true }));
      unmount();
      // assert listeners removed — easiest via spying on document.removeEventListener
    });
    
    This stays in the suite forever and prevents the bug from coming back.

Concerns

  1. @DirtiesContext(classMode = AFTER_EACH_TEST_METHOD) in DocumentDensityIntegrationTest restarts the full Spring context per test method — for 7 tests in this class that's 7× full app boot (≈10–15s each in our config). The whole class runs alone in the suite when it doesn't need to. Replace with:

    @SpringBootTest(...)
    @ActiveProfiles("test")
    @Import(PostgresContainerConfig.class)
    @Transactional
    class DocumentDensityIntegrationTest { ... }
    

    Each test rolls back its own writes, no context restart. CI time drops from ~90s to ~15s for this class alone.

  2. Accessibility regression coverage missing. The widget exposes role="group", aria-pressed, an aria-live="polite" region, and aria-label on each bar — but none of that is asserted with axe. Add to the documents page E2E:

    import AxeBuilder from '@axe-core/playwright';
    test('documents page with timeline passes a11y', async ({ page }) => {
      await page.goto('/documents?q=Brief');
      const results = await new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa']).analyze();
      expect(results.violations).toEqual([]);
    });
    

    If the widget hides below lg (1024px), set viewport: { width: 1280, height: 900 }.

Suggestions

  • The component spec has 25 cases for visibility, axes, click, drag, zoom — strong coverage. The one thing missing is the keyboard path: focus a bar, press Enter, assert onchange fires with the right from/to. Aria-pressed is set; let's prove it works.
  • formatTickLabel is locale-dependent. The test at timeline.spec.ts:330ish likely hardcodes English month abbreviations — make sure CI's locale matches or pass an explicit locale to keep the test deterministic across runners.
  • The flake risk in this PR is low — no Thread.sleep, no global timers, no real network — appreciated.

LGTM

  • Test names read as sentences.
  • Real Postgres via Testcontainers (no H2).
  • Boundary conditions tested: no docs, only nulls, FTS short-circuit, empty filter, sender + tag combo, status filter.
  • 401 vs 200 distinguished.
  • Cache-Control header pinned (density_emitsPrivateCacheControlHeader).
  • Filter forwarding tested with verify(documentService).getDensity(eq(...), ...) — proves the controller passes params through, doesn't just smoke-test.
## 🧪 Sara Holt — QA Engineer **Verdict: ⚠️ Approved with concerns** The pyramid is well-balanced for this feature: 4 service unit tests, 5 controller `@WebMvcTest` tests including auth and headers, 7 Testcontainer integration tests on real Postgres, plus 25 component tests and 42 pure-function tests on the frontend. That's coverage I trust. Three things to fix or follow up. ### Blockers 1. **No regression test for the listener-leak path Felix flagged.** When `TimelineDensityFilter` unmounts mid-drag, document-level `pointermove`/`pointerup`/`pointercancel` listeners stay attached. Add a vitest-browser-svelte test: ```ts it('removes document listeners on unmount mid-drag', async () => { const { unmount } = render(TimelineDensityFilter, makeProps()); const bar = document.querySelector('[data-testid="timeline-bar"]') as HTMLButtonElement; bar.dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true })); unmount(); // assert listeners removed — easiest via spying on document.removeEventListener }); ``` This stays in the suite forever and prevents the bug from coming back. ### Concerns 2. **`@DirtiesContext(classMode = AFTER_EACH_TEST_METHOD)` in `DocumentDensityIntegrationTest`** restarts the full Spring context per test method — for 7 tests in this class that's 7× full app boot (≈10–15s each in our config). The whole class runs alone in the suite when it doesn't need to. Replace with: ```java @SpringBootTest(...) @ActiveProfiles("test") @Import(PostgresContainerConfig.class) @Transactional class DocumentDensityIntegrationTest { ... } ``` Each test rolls back its own writes, no context restart. CI time drops from ~90s to ~15s for this class alone. 3. **Accessibility regression coverage missing.** The widget exposes `role="group"`, `aria-pressed`, an `aria-live="polite"` region, and `aria-label` on each bar — but none of that is asserted with axe. Add to the documents page E2E: ```ts import AxeBuilder from '@axe-core/playwright'; test('documents page with timeline passes a11y', async ({ page }) => { await page.goto('/documents?q=Brief'); const results = await new AxeBuilder({ page }) .withTags(['wcag2a', 'wcag2aa']).analyze(); expect(results.violations).toEqual([]); }); ``` If the widget hides below `lg` (1024px), set `viewport: { width: 1280, height: 900 }`. ### Suggestions - The component spec has 25 cases for visibility, axes, click, drag, zoom — strong coverage. The one thing missing is the keyboard path: focus a bar, press Enter, assert `onchange` fires with the right `from`/`to`. Aria-pressed is set; let's prove it works. - `formatTickLabel` is locale-dependent. The test at `timeline.spec.ts:330ish` likely hardcodes English month abbreviations — make sure CI's locale matches or pass an explicit locale to keep the test deterministic across runners. - The flake risk in this PR is low — no `Thread.sleep`, no global timers, no real network — appreciated. ### LGTM - Test names read as sentences. - Real Postgres via Testcontainers (no H2). - Boundary conditions tested: no docs, only nulls, FTS short-circuit, empty filter, sender + tag combo, status filter. - 401 vs 200 distinguished. - Cache-Control header pinned (`density_emitsPrivateCacheControlHeader`). - Filter forwarding tested with `verify(documentService).getDensity(eq(...), ...)` — proves the controller passes params through, doesn't just smoke-test.
Author
Owner

🎨 Leonie Voss — UX & Accessibility

Verdict: 🚫 Changes requested

Lots to like — the widget uses our brand tokens, respects prefers-reduced-motion, computes a 3.33:1 dark-mode bar contrast that clears WCAG 1.4.11, exposes an aria-live drag preview, and has aria-label + aria-pressed on every bar. But two accessibility blockers and one broken-promise breakpoint that I can't approve through.

Blockers

  1. Touch targets in TimelineControls fail WCAG 2.5.8. TimelineControls.svelte:18 and :25:

    class="... h-6 ... px-2 ..."        <!-- 24px tall reset button -->
    class="... h-6 w-6 ..."             <!-- 24×24 clear-selection button -->
    

    h-6 is 24px — that's the WCAG 2.2 floor, but our project standard for the senior audience (60+) is 44×44 minimum, prefer 48×48. Even though the widget hides below lg, seniors absolutely use desktop. Fix:

    class="inline-flex h-11 min-w-[44px] items-center justify-center rounded-sm px-3 text-xs ..."
    
  2. No keyboard alternative to drag-zoom. Single-bar selection works (the bar is a <button>, Enter/Space fires onclick, suppressClick only suppresses post-pointerup synthesized clicks). But range selection is mouse-only — there is no keyboard path to select a range. WCAG 2.1.1 ("Keyboard") makes this a Level A failure. Two reasonable options:

    • (a) Shift+Click on a second bar extends the range, then commits filter+zoom on a final Enter / dedicated "Apply range" button.
    • (b) Two-step: first Enter sets from, second Enter on a later bar sets to and commits. Show the in-progress state via the existing aria-live region.
      Either path needs a vitest-browser test asserting the keyboard journey. Pure mouse drag is not enough.
  3. Documented breakpoint contradicts the implementation. The l3-frontend C4 diagram and the description say "tablet/desktop only" / (min-width: 640px), but the code uses (min-width: 1024px) (the lg token). 1024px excludes iPad portrait (768px) and 13" laptops in side-by-side. This affects the user persona split — readers on tablets will lose this feature silently. Decide and align.

Concerns

  • No focus-visible style on the bar buttons. They render with bg-transparent p-0 — so the default browser outline is what keyboard users get, and our project standard is focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2. Add the rings to .bar so keyboard users can see where they are inside the timeline.

  • timeline_bar_aria plurals are wrong. All three locales hardcode the plural form: "{count} documents", "{count} Dokumente", "{count} documentos". For count = 1 a screen reader announces "August 1915, 1 documents". Use Paraglide's ICU plural form. NFR: a11y + i18n quality.

  • :root selector in a scoped Svelte <style> block (TimelineBars.svelte:69) is a code smell. It defines --timeline-bar-idle globally even though the style block is scoped — it works because :root is a global selector, but it spreads component CSS contracts into the global namespace. Move the variable definitions into layout.css next to the other --palette-* tokens, or scope them to the timeline root with a class selector.

Suggestions

  • The drag preview is spatially clear (mint-bordered window expanding live) — nice. Worth a tiny <small> text hint above the timeline on first interaction: "Drag across bars to zoom into a range." Discoverability of the drag affordance is a known weak point in Graylog-style selectors.
  • Consider what happens visually when count === 0 for many consecutive months — the 2px sentinel bars will read as a near-empty row. That's correct information, but a subtle muted background stripe (already mostly there via border-line) helps the user perceive "yes, this is the selected/visible area, not a render bug."
  • Empty state when density.length === 0 after filtering: the widget renders {#if density !== null} but if all buckets are filtered out, filled is empty and you get an invisible bar row plus the controls. A small message ("No documents in this filter") would help.

LGTM

  • Brand tokens used (bg-surface, border-line, text-ink-3, --palette-mint).
  • Dark-mode bar contrast verified at 3.33:1, comment explains the previous failure — exactly the rigor I want.
  • prefers-reduced-motion respected.
  • aria-live="polite" for drag preview, polite-tone (correct vs assertive).
  • Reset () and clear (×) icons have aria-label + title.
  • Components named after visible regions (Bars, XAxis, YAxis, Controls).
## 🎨 Leonie Voss — UX & Accessibility **Verdict: 🚫 Changes requested** Lots to like — the widget uses our brand tokens, respects `prefers-reduced-motion`, computes a 3.33:1 dark-mode bar contrast that clears WCAG 1.4.11, exposes an aria-live drag preview, and has `aria-label` + `aria-pressed` on every bar. But two accessibility blockers and one broken-promise breakpoint that I can't approve through. ### Blockers 1. **Touch targets in `TimelineControls` fail WCAG 2.5.8.** `TimelineControls.svelte:18` and `:25`: ```svelte class="... h-6 ... px-2 ..." <!-- 24px tall reset button --> class="... h-6 w-6 ..." <!-- 24×24 clear-selection button --> ``` `h-6` is 24px — that's the WCAG 2.2 floor, but our project standard for the senior audience (60+) is **44×44 minimum, prefer 48×48**. Even though the widget hides below `lg`, seniors absolutely use desktop. Fix: ```svelte class="inline-flex h-11 min-w-[44px] items-center justify-center rounded-sm px-3 text-xs ..." ``` 2. **No keyboard alternative to drag-zoom.** Single-bar selection works (the bar is a `<button>`, Enter/Space fires `onclick`, `suppressClick` only suppresses post-pointerup synthesized clicks). But **range selection is mouse-only** — there is no keyboard path to select a range. WCAG 2.1.1 ("Keyboard") makes this a Level A failure. Two reasonable options: - **(a)** Shift+Click on a second bar extends the range, then commits filter+zoom on a final Enter / dedicated "Apply range" button. - **(b)** Two-step: first Enter sets `from`, second Enter on a later bar sets `to` and commits. Show the in-progress state via the existing `aria-live` region. Either path needs a vitest-browser test asserting the keyboard journey. Pure mouse drag is not enough. 3. **Documented breakpoint contradicts the implementation.** The l3-frontend C4 diagram and the description say "tablet/desktop only" / `(min-width: 640px)`, but the code uses `(min-width: 1024px)` (the `lg` token). 1024px excludes iPad portrait (768px) and 13" laptops in side-by-side. This affects the user persona split — readers on tablets will lose this feature silently. Decide and align. ### Concerns - **No focus-visible style on the bar buttons.** They render with `bg-transparent p-0` — so the default browser outline is what keyboard users get, and our project standard is `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2`. Add the rings to `.bar` so keyboard users can see where they are inside the timeline. - **`timeline_bar_aria` plurals are wrong.** All three locales hardcode the plural form: `"{count} documents"`, `"{count} Dokumente"`, `"{count} documentos"`. For `count = 1` a screen reader announces "August 1915, 1 documents". Use Paraglide's ICU plural form. NFR: a11y + i18n quality. - **`:root` selector in a scoped Svelte `<style>` block** (`TimelineBars.svelte:69`) is a code smell. It defines `--timeline-bar-idle` globally even though the style block is scoped — it works because `:root` is a global selector, but it spreads component CSS contracts into the global namespace. Move the variable definitions into `layout.css` next to the other `--palette-*` tokens, or scope them to the timeline root with a class selector. ### Suggestions - The drag preview is spatially clear (mint-bordered window expanding live) — nice. Worth a tiny `<small>` text hint above the timeline on first interaction: "Drag across bars to zoom into a range." Discoverability of the drag affordance is a known weak point in Graylog-style selectors. - Consider what happens visually when `count === 0` for many consecutive months — the 2px sentinel bars will read as a near-empty row. That's correct information, but a subtle muted background stripe (already mostly there via `border-line`) helps the user perceive "yes, this is the selected/visible area, not a render bug." - Empty state when `density.length === 0` after filtering: the widget renders `{#if density !== null}` but if all buckets are filtered out, `filled` is empty and you get an invisible bar row plus the controls. A small message ("No documents in this filter") would help. ### LGTM - Brand tokens used (`bg-surface`, `border-line`, `text-ink-3`, `--palette-mint`). - Dark-mode bar contrast verified at 3.33:1, comment explains the previous failure — exactly the rigor I want. - `prefers-reduced-motion` respected. - `aria-live="polite"` for drag preview, polite-tone (correct vs assertive). - Reset (`↩`) and clear (`×`) icons have `aria-label` + `title`. - Components named after visible regions (`Bars`, `XAxis`, `YAxis`, `Controls`).
marcel added 12 commits 2026-05-08 11:12:20 +02:00
The index was added in anticipation of a SQL GROUP BY aggregation,
but DocumentService.getDensity aggregates in memory via
findAll(spec).stream(). The index is never touched by the current
query plan. Per Markus's round-2 review: drop the unused migration
to avoid mismatched rationale-vs-implementation debt. Revisit when
the archive crosses 50k rows (TODO already in getDensity Javadoc).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
YearMonth.from(d).toString() emits the same canonical YYYY-MM string
as the previous String.format("%04d-%02d", …) call but reads as a
single intent-revealing expression. Existing assertions on
"1915-08", "1916-01", … pin the output format unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The empty-result case returns null for both bounds, which the TS
codegen surfaces as optional. Future contributors should not "fix"
the missing @Schema(REQUIRED) — it is deliberate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces @DirtiesContext(AFTER_EACH_TEST_METHOD), which restarted
the full Spring context per test (≈10–15s × 7), with @Transactional
rollback. Each test still sees a clean slate via the spring-test
default rollback, but the context is shared across the class.

Wall time for this class dropped from 35s to 17.87s in local runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously a 5xx, network blip, or JSON parse error all collapsed
into the same silent "no buckets" rendering. The widget still
degrades gracefully — failure should not block the document list —
but operators and Sentry now see the failure in browser devtools
instead of having to reverse-engineer a missing chart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pointerdown attaches three document-level listeners. Without an
explicit teardown, an unmount mid-drag (route change, view toggle,
viewport drops below lg) left them attached and they kept writing
to torn-down state cells.

Wrap the cleanup in $effect's return, which Svelte 5 invokes on
unmount. The listener-removal regression test pins this so the bug
cannot come back silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WCAG 2.5.8 (target size, AA) requires 44×44 minimum, and the
project's senior persona makes that a hard floor on desktop too.
Reset-zoom: h-6 → h-11 + min-w-[44px] + px-3.
Clear-selection: h-6 w-6 → h-11 w-11.

Two regression tests on the TimelineDensityFilter spec assert the
sized classes so a future shrink can't slip through silently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bar buttons rendered with bg-transparent + p-0 fell back to the
default browser outline, which is invisible against bg-surface for
keyboard users. Adds the project-standard focus ring
(ring-2/brand-navy/offset-2) so the focused bar reads as focused.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The flat "{count} Dokumente / documents / documentos" keys read as
"1 Dokumente" / "1 documents" / "1 documentos" to a screen reader
when only one document falls in the month bucket. Splits each
locale into _singular + _plural keys and picks the form by count
in TimelineBars, mirroring the existing upload_banner_singular /
_plural pattern in this project.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defining --timeline-bar-idle / --timeline-bar-outside on :root from
inside a scoped <style> block leaks the contract into the global
namespace via component-local CSS, even though the selector itself
makes it work. Move both variables to layout.css next to the other
--palette / --c-* design tokens; the component <style> now only
consumes them.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The function has a single in-source call site (TimelineDensityFilter)
but is exported so timeline.spec.ts can pin its boundary semantics
without rendering the orchestrator. Note that explicitly so future
readers don't treat the export as a public API contract.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs(c4): align density breakpoint with code (≥1024px) (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m4s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m17s
CI / Unit & Component Tests (push) Failing after 4m3s
CI / OCR Service Tests (push) Successful in 40s
CI / Backend Unit Tests (push) Failing after 3m22s
301cfffd1a
The widget hides below the Tailwind lg breakpoint to protect the
44×44 touch-target floor on tablet (Leonie's round-1 finding) but
the diagram still claimed 640px (sm). Update both the docsListPageTs
description, the timelineFilter description, and the relationship
label to match +page.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Round-2 review concerns addressed

Pushed 12 atomic commits resolving the open round-2 concerns. Branch: feat/issue-385-timeline-density-filter.

Resolved in this round

# Reviewer Concern Commit
1 @Markus blocker 1 V61 index dead code → drop the migration; in-memory aggregation stays (per maintainer decision); revisit at >50k via the existing TODO in DocumentService.getDensity 360db1ae
2 @Felix suggestion String.format("%04d-%02d", …)YearMonth.from(d).toString() 47841b91
3 @Markus suggestion Comment on DocumentDensityResult explaining why minDate/maxDate deliberately omit @Schema(REQUIRED) ffe617db
4 @Sara concern 2 / @Felix suggestion DocumentDensityIntegrationTest: @DirtiesContext(AFTER_EACH_TEST_METHOD)@Transactional rollback. Class wall time dropped from ~35s to ~18s locally c9be6cc1
5 @Felix blocker 2 / @Nora supporting fetchDensity now console.warns on non-ok status and on caught error so outages aren't invisible to ops/Sentry 2e9ce8e1
6 @Felix blocker 1 / @Sara blocker 1 Document pointer listeners cleaned up via $effect return on unmount mid-drag — with a regression test that pins the behaviour 3b6b117c
7 @Leonie blocker 1 TimelineControls reset-zoom + clear buttons bumped from h-6 to h-11/min-w-[44px]/w-11 — clears WCAG 2.5.8 (AAA prefer-48 will be revisited if Leonie pushes) 153752a9
8 @Leonie concern focus-visible:ring-2 ring-brand-navy ring-offset-2 on bar buttons so keyboard users can see focus 48da819a
9 @Elicit concern 1 / @Leonie concern timeline_bar_aria split into _singular + _plural per project convention (mirrors upload_banner_*); TimelineBars picks the form by count. No more "1 documents/Dokumente/documentos" 1d6016cb
10 @Leonie concern --timeline-bar-idle / --timeline-bar-outside moved out of TimelineBars's scoped <style> :root and into layout.css next to the rest of the design tokens 5d749b24
11 @Felix suggestion clipBucketsToRange carries an @internal JSDoc note explaining why it is exported (so timeline.spec.ts can pin boundaries directly) bf501b7d
12 @Markus blocker 2 / @Elicit concern 2 / @Leonie blocker 3 l3-frontend-3b-document-workflows.puml updated: '(min-width: 640px)''(min-width: 1024px)', "tablet/desktop only" → "desktop only (≥1024px)". PR description also aligned.

🔁 Deferred (per the round-1 discussion + Markus's defer agreement)

Concern Follow-up
@Leonie blocker 2 — keyboard equivalent for drag-to-zoom (WCAG 2.1.1) #479
@Sara concern 3 / @Elicit concern — Playwright E2E + axe coverage #480
@Markus #2 round-1 — extract DocumentSearchCriteria record Defer until a third aggregation endpoint lands
@Tobi round-1 — shared Cache-Control policy Defer until a second cacheable read endpoint lands
@Sara suggestion — locale-deterministic formatTickLabel test Optional — current tests pass against getLocale() so no flake observed

Verification

  • cd backend && ./mvnw test — 1540/1540 ✓ (BUILD SUCCESS)
  • cd frontend && npx vitest run src/lib/document src/routes/documents — 551/551 ✓ across 54 files
  • cd frontend && npm run lint — clean
  • cd frontend && npx svelte-check — 0 new errors in any touched file (261 pre-existing in unrelated modules)

— Felix

## 👨‍💻 Felix Brandt — Round-2 review concerns addressed Pushed 12 atomic commits resolving the open round-2 concerns. Branch: `feat/issue-385-timeline-density-filter`. ### ✅ Resolved in this round | # | Reviewer | Concern | Commit | |---|---|---|---| | 1 | @Markus blocker 1 | V61 index dead code → drop the migration; in-memory aggregation stays (per maintainer decision); revisit at >50k via the existing TODO in `DocumentService.getDensity` | `360db1ae` | | 2 | @Felix suggestion | `String.format("%04d-%02d", …)` → `YearMonth.from(d).toString()` | `47841b91` | | 3 | @Markus suggestion | Comment on `DocumentDensityResult` explaining why `minDate`/`maxDate` deliberately omit `@Schema(REQUIRED)` | `ffe617db` | | 4 | @Sara concern 2 / @Felix suggestion | `DocumentDensityIntegrationTest`: `@DirtiesContext(AFTER_EACH_TEST_METHOD)` → `@Transactional` rollback. Class wall time dropped from ~35s to ~18s locally | `c9be6cc1` | | 5 | @Felix blocker 2 / @Nora supporting | `fetchDensity` now `console.warn`s on non-ok status and on caught error so outages aren't invisible to ops/Sentry | `2e9ce8e1` | | 6 | @Felix blocker 1 / @Sara blocker 1 | Document pointer listeners cleaned up via `$effect` return on unmount mid-drag — with a regression test that pins the behaviour | `3b6b117c` | | 7 | @Leonie blocker 1 | `TimelineControls` reset-zoom + clear buttons bumped from `h-6` to `h-11`/`min-w-[44px]`/`w-11` — clears WCAG 2.5.8 (AAA prefer-48 will be revisited if Leonie pushes) | `153752a9` | | 8 | @Leonie concern | `focus-visible:ring-2 ring-brand-navy ring-offset-2` on bar buttons so keyboard users can see focus | `48da819a` | | 9 | @Elicit concern 1 / @Leonie concern | `timeline_bar_aria` split into `_singular` + `_plural` per project convention (mirrors `upload_banner_*`); `TimelineBars` picks the form by count. No more "1 documents/Dokumente/documentos" | `1d6016cb` | | 10 | @Leonie concern | `--timeline-bar-idle` / `--timeline-bar-outside` moved out of `TimelineBars`'s scoped `<style> :root` and into `layout.css` next to the rest of the design tokens | `5d749b24` | | 11 | @Felix suggestion | `clipBucketsToRange` carries an `@internal` JSDoc note explaining why it is exported (so `timeline.spec.ts` can pin boundaries directly) | `bf501b7d` | | 12 | @Markus blocker 2 / @Elicit concern 2 / @Leonie blocker 3 | `l3-frontend-3b-document-workflows.puml` updated: `'(min-width: 640px)'` → `'(min-width: 1024px)'`, "tablet/desktop only" → "desktop only (≥1024px)". PR description also aligned. | ### 🔁 Deferred (per the round-1 discussion + Markus's defer agreement) | Concern | Follow-up | |---|---| | @Leonie blocker 2 — keyboard equivalent for drag-to-zoom (WCAG 2.1.1) | #479 | | @Sara concern 3 / @Elicit concern — Playwright E2E + axe coverage | #480 | | @Markus #2 round-1 — extract `DocumentSearchCriteria` record | Defer until a third aggregation endpoint lands | | @Tobi round-1 — shared `Cache-Control` policy | Defer until a second cacheable read endpoint lands | | @Sara suggestion — locale-deterministic `formatTickLabel` test | Optional — current tests pass against `getLocale()` so no flake observed | ### Verification - `cd backend && ./mvnw test` — 1540/1540 ✓ (BUILD SUCCESS) - `cd frontend && npx vitest run src/lib/document src/routes/documents` — 551/551 ✓ across 54 files - `cd frontend && npm run lint` — clean - `cd frontend && npx svelte-check` — 0 new errors in any touched file (261 pre-existing in unrelated modules) — Felix
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

A clean, boring, well-bounded slice. The chart sits inside the existing document/ package, the controller delegates to the service, the service composes the existing Specification instead of introducing a parallel query path. No new modules, no new transport protocols, no new infrastructure. This is exactly how I want feature work to land.

What I checked

  • Module boundariesDocumentController.density()DocumentService.getDensity()documentRepository.findAll(spec). No cross-domain reach. tagService.expandTagNamesToDescendantIdSets(...) (visible in the unit-test mock) goes through TagService, which is the boundary I expect.
  • Layering — controller is thin (header + delegate + cache header), service owns the aggregation logic. No business logic leaked into the controller.
  • Schema impact — none. No Flyway migration, no new column. The aggregation is read-only.
  • Documentation currency — both C4 diagrams updated (docs/architecture/c4/l3-backend-3b-document-management.puml adds the density description; docs/architecture/c4/l3-frontend-3b-document-workflows.puml adds docsListPageTs and timelineFilter components plus the new HTTP edge). This is the part most PRs miss; well done.
  • DTOs are domain-localDocumentDensityResult and MonthBucket live in org.raddatz.familienarchiv.document. Not shared.

Suggestions (non-blocking)

  1. In-memory aggregation TODO at >50k rows (DocumentService.getDensity Javadoc, line ~98). The trade-off is correctly named and the threshold is concrete. Consider opening a follow-up issue now tagged perf so it isn't lost; comments age into invisibility. The migration target is also clear (GROUP BY TO_CHAR(meta_date, 'YYYY-MM')), which means future-me has a starting point.
  2. */* content type on the OpenAPI spec for density (frontend/src/lib/generated/api.ts:2485) — Spring is producing the generic media-type because the controller doesn't declare produces = MediaType.APPLICATION_JSON_VALUE. The other read endpoints in this controller likely have the same shape, so this is a project-wide tidy, not a blocker for this PR. Worth a one-line produces = ... when someone next touches the controller signature.
  3. ADR opportunity (or not) — "filter-reactive density that intentionally ignores from/to" is a non-obvious design rule encoded in three places (Javadoc, integration test class comment, buildDensityUrl test). That's load-bearing context. If anyone reaches for "let's just forward all filters" in 6 months, an ADR or docs/specs/ note would prevent a regression. Not a blocker — the code comments are unusually good — but it's the kind of thing future-Markus would thank present-Markus for writing down.

What I did NOT need to flag

  • No microservice extraction temptation. No new broker. No WebSocket where SSE/HTTP would do. No premature caching layer in front of the in-memory aggregation. The Cache-Control: private, max-age=300 does the work browser-side without standing up Redis.
  • The +page.ts CSR loader is the right call for a chart that the SSR-friendly main list shouldn't wait on. Justified deviation from SSR-default.

Boring, readable, scoped. Ship it.

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** A clean, boring, well-bounded slice. The chart sits inside the existing `document/` package, the controller delegates to the service, the service composes the existing `Specification` instead of introducing a parallel query path. No new modules, no new transport protocols, no new infrastructure. This is exactly how I want feature work to land. ### What I checked - **Module boundaries** — `DocumentController.density()` → `DocumentService.getDensity()` → `documentRepository.findAll(spec)`. No cross-domain reach. `tagService.expandTagNamesToDescendantIdSets(...)` (visible in the unit-test mock) goes through `TagService`, which is the boundary I expect. ✅ - **Layering** — controller is thin (header + delegate + cache header), service owns the aggregation logic. No business logic leaked into the controller. ✅ - **Schema impact** — none. No Flyway migration, no new column. The aggregation is read-only. ✅ - **Documentation currency** — both C4 diagrams updated (`docs/architecture/c4/l3-backend-3b-document-management.puml` adds the density description; `docs/architecture/c4/l3-frontend-3b-document-workflows.puml` adds `docsListPageTs` and `timelineFilter` components plus the new HTTP edge). This is the part most PRs miss; well done. ✅ - **DTOs are domain-local** — `DocumentDensityResult` and `MonthBucket` live in `org.raddatz.familienarchiv.document`. Not shared. ✅ ### Suggestions (non-blocking) 1. **In-memory aggregation TODO at >50k rows** (`DocumentService.getDensity` Javadoc, line ~98). The trade-off is correctly named and the threshold is concrete. Consider opening a follow-up issue *now* tagged `perf` so it isn't lost; comments age into invisibility. The migration target is also clear (`GROUP BY TO_CHAR(meta_date, 'YYYY-MM')`), which means future-me has a starting point. 2. **`*/*` content type on the OpenAPI spec** for `density` (`frontend/src/lib/generated/api.ts:2485`) — Spring is producing the generic media-type because the controller doesn't declare `produces = MediaType.APPLICATION_JSON_VALUE`. The other read endpoints in this controller likely have the same shape, so this is a project-wide tidy, not a blocker for this PR. Worth a one-line `produces = ...` when someone next touches the controller signature. 3. **ADR opportunity (or not)** — "filter-reactive density that intentionally ignores `from`/`to`" is a non-obvious design rule encoded in three places (Javadoc, integration test class comment, `buildDensityUrl` test). That's load-bearing context. If anyone reaches for "let's just forward all filters" in 6 months, an ADR or `docs/specs/` note would prevent a regression. Not a blocker — the code comments are unusually good — but it's the kind of thing future-Markus would thank present-Markus for writing down. ### What I did NOT need to flag - No microservice extraction temptation. No new broker. No WebSocket where SSE/HTTP would do. No premature caching layer in front of the in-memory aggregation. The `Cache-Control: private, max-age=300` does the work browser-side without standing up Redis. - The `+page.ts` CSR loader is the right call for a chart that the SSR-friendly main list shouldn't wait on. Justified deviation from SSR-default. ✅ Boring, readable, scoped. Ship it.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: ⚠️ Approved with concerns

TDD evidence is strong (44 timeline-helper specs + 35 component specs + 7 service unit/integration tests, all in front of meaningful implementations), guard clauses replace nesting, names reveal intent. Two readability concerns in the orchestrator, a few minor nits, nothing blocking.

Concerns

1. TimelineDensityFilter.svelte is 286 lines (frontend/src/lib/document/TimelineDensityFilter.svelte). The bar/axis/controls children were extracted — good — but the orchestrator now carries five concerns: drag state machine, selection-vs-zoom routing, drag-window math, aria-live message derivation, and click-vs-drag suppression. Each is justified individually, but together they tip past the "one nameable region" rule.

If you want to split later, the cleanest cleavage is the drag state machine — dragStartIndex, dragEndIndex, suppressClick, handleDocumentMove/Up/Cancel, cleanupDragListeners, finalizeDrag, indexFromClientX, dragWindowLeftPct/RightPct, dragLiveMessage form a coherent unit that could live in a useTimelineDrag.svelte.ts rune-class. That would leave the component as a thin choreographer (~140 lines), and the drag state would be unit-testable without a DOM. Not a blocker for this PR — flag for the perf/cleanup pass.

2. DocumentService.getDensity does three things (backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java:109): FTS short-circuit, spec build + load, in-memory grouping. That's at the edge of "20 lines, one thing". A small refactor would be:

public DocumentDensityResult getDensity(...) {
    List<UUID> rankedIds = resolveFtsIds(text);
    if (text != null && rankedIds != null && rankedIds.isEmpty()) {
        return new DocumentDensityResult(List.of(), null, null);
    }
    List<LocalDate> dates = loadFilteredDates(text, rankedIds, sender, receiver, tags, tagQ, status, tagOperator);
    return aggregateByMonth(dates);
}

Each helper is testable in isolation. Right now the unit-test pyramid leans on heavier findAll(any(Specification.class)) mocks than necessary. Again, non-blocking — the existing tests do work.

Suggestions (nice-to-have)

  • Parameter object for getDensity — 7 positional args (text, sender, receiver, tags, tagQ, status, tagOperator) is the kind of signature where the next caller swaps two UUIDs by accident. A DensityFilters record (mirroring the TS DensityFilters type) would lock the order at compile time.
  • @SuppressWarnings("rawtypes") on the Specification.class matchersany(Specification.class) triggers an unchecked-warning for the diamond. any(Specification.class) cast or ArgumentMatchers.<Specification<Document>>any() reads cleaner. Cosmetic.
  • monthBoundaryTo cleverness (frontend/src/lib/document/timeline.ts:43) — new Date(Date.UTC(year, month, 0)).getUTCDate() exploits that day-0 of next month equals the last day of current month. Correct for leap years (the test suite proves it: 1916-02 → 29), but the trick is invisible to a future reader. A one-line "day 0 of month+1 ≡ last day of month" comment would help. The "no comments unless non-obvious" rule applies here: this is non-obvious.
  • console.warn on density failure (fetchDensity) is the right call for graceful degradation — the chart is an enhancement, the list still renders. The codepath is intentionally distinct from getErrorMessage() because there's no user-facing error to localise. Confirming this is the right pattern, not flagging it as wrong.

What's done well — explicitly

  • Pure helpers in timeline.ts are exemplary. Twelve standalone functions, each one expression or a single loop, each with a focused test block. This is the "make business logic testable by extracting it from the component" rule applied perfectly.
  • @derived/@derived.by everywhere instead of $effect for computed values. monthBuckets, filled, isZoomed, maxCount, hasSelection, dragLowIndex, dragHighIndex, dragWindowLeftPct/RightPct, dragLiveMessage — every reactive value is $derived. No effects-as-derivation anti-patterns.
  • Keyed {#each} on bars ((bucket.month)).
  • SvelteMap not used — and not needed; the data is plain arrays.
  • Cleanup $effect(() => cleanupDragListeners) for unmount mid-drag. The test ('removes document pointer listeners when unmounted mid-drag') proves it.
  • DomainException not raw exceptions — the controller delegates to a service that doesn't throw; the empty-result path returns an empty DocumentDensityResult. No Optional.get(), no RuntimeException.

Verdict

Component-size flag is a "next pass" concern, not a blocker for this PR. Ship it; come back for the drag-state extraction in the next density iteration.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ⚠️ Approved with concerns** TDD evidence is strong (44 timeline-helper specs + 35 component specs + 7 service unit/integration tests, all in front of meaningful implementations), guard clauses replace nesting, names reveal intent. Two readability concerns in the orchestrator, a few minor nits, nothing blocking. ### Concerns **1. `TimelineDensityFilter.svelte` is 286 lines** (`frontend/src/lib/document/TimelineDensityFilter.svelte`). The bar/axis/controls children were extracted — good — but the orchestrator now carries five concerns: drag state machine, selection-vs-zoom routing, drag-window math, aria-live message derivation, and click-vs-drag suppression. Each is justified individually, but together they tip past the "one nameable region" rule. If you want to split later, the cleanest cleavage is the drag state machine — `dragStartIndex`, `dragEndIndex`, `suppressClick`, `handleDocumentMove/Up/Cancel`, `cleanupDragListeners`, `finalizeDrag`, `indexFromClientX`, `dragWindowLeftPct/RightPct`, `dragLiveMessage` form a coherent unit that could live in a `useTimelineDrag.svelte.ts` rune-class. That would leave the component as a thin choreographer (~140 lines), and the drag state would be unit-testable without a DOM. **Not a blocker for this PR — flag for the perf/cleanup pass.** **2. `DocumentService.getDensity` does three things** (`backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java:109`): FTS short-circuit, spec build + load, in-memory grouping. That's at the edge of "20 lines, one thing". A small refactor would be: ```java public DocumentDensityResult getDensity(...) { List<UUID> rankedIds = resolveFtsIds(text); if (text != null && rankedIds != null && rankedIds.isEmpty()) { return new DocumentDensityResult(List.of(), null, null); } List<LocalDate> dates = loadFilteredDates(text, rankedIds, sender, receiver, tags, tagQ, status, tagOperator); return aggregateByMonth(dates); } ``` Each helper is testable in isolation. Right now the unit-test pyramid leans on heavier `findAll(any(Specification.class))` mocks than necessary. Again, non-blocking — the existing tests do work. ### Suggestions (nice-to-have) - **Parameter object for `getDensity`** — 7 positional args (`text, sender, receiver, tags, tagQ, status, tagOperator`) is the kind of signature where the next caller swaps two `UUID`s by accident. A `DensityFilters` record (mirroring the TS `DensityFilters` type) would lock the order at compile time. - **`@SuppressWarnings("rawtypes")` on the `Specification.class` matchers** — `any(Specification.class)` triggers an unchecked-warning for the diamond. `any(Specification.class)` cast or `ArgumentMatchers.<Specification<Document>>any()` reads cleaner. Cosmetic. - **`monthBoundaryTo` cleverness** (`frontend/src/lib/document/timeline.ts:43`) — `new Date(Date.UTC(year, month, 0)).getUTCDate()` exploits that day-0 of next month equals the last day of current month. Correct for leap years (the test suite proves it: `1916-02 → 29`), but the trick is invisible to a future reader. A one-line "`day 0` of `month+1` ≡ last day of `month`" comment would help. The "no comments unless non-obvious" rule applies here: this *is* non-obvious. - **`console.warn` on density failure** (`fetchDensity`) is the right call for graceful degradation — the chart is an enhancement, the list still renders. The codepath is intentionally distinct from `getErrorMessage()` because there's no user-facing error to localise. ✅ Confirming this is the right pattern, not flagging it as wrong. ### What's done well — explicitly - **Pure helpers in `timeline.ts`** are exemplary. Twelve standalone functions, each one expression or a single loop, each with a focused test block. This is the "make business logic testable by extracting it from the component" rule applied perfectly. - **`@derived`/`@derived.by` everywhere instead of `$effect` for computed values.** `monthBuckets`, `filled`, `isZoomed`, `maxCount`, `hasSelection`, `dragLowIndex`, `dragHighIndex`, `dragWindowLeftPct/RightPct`, `dragLiveMessage` — every reactive value is `$derived`. No effects-as-derivation anti-patterns. - **Keyed `{#each}`** on bars (`(bucket.month)`). ✅ - **`SvelteMap` not used** — and not needed; the data is plain arrays. ✅ - **Cleanup `$effect(() => cleanupDragListeners)`** for unmount mid-drag. The test (`'removes document pointer listeners when unmounted mid-drag'`) proves it. ✅ - **`DomainException` not raw exceptions** — the controller delegates to a service that doesn't throw; the empty-result path returns an empty `DocumentDensityResult`. No `Optional.get()`, no `RuntimeException`. ✅ ### Verdict Component-size flag is a "next pass" concern, not a blocker for this PR. Ship it; come back for the drag-state extraction in the next density iteration.
Author
Owner

🛠️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved (LGTM — nothing to operate)

No infrastructure surface in this PR. No Compose changes, no CI workflow edits, no new service, no new env var, no new secret, no new image, no new volume, no new exposed port. The 25-file diff lives entirely in backend/, frontend/, and docs/. From an ops chair this is a code-only change — there's literally nothing for me to size, monitor, or alert on.

What I checked anyway

  • No new Docker servicedocker-compose*.yml untouched.
  • No new env varapplication*.yaml and .env* untouched.
  • No new dependencypom.xml and package.json untouched, so no Renovate config update needed.
  • No new outbound integration — endpoint stays inside the existing backend ↔ frontend HTTP boundary. No new firewall hole.
  • No actuator surface change/api/documents/density is under /api/* like the rest of the application traffic. Nothing to block at Caddy.
  • No log volume concern — one console.warn on density-fetch failure (degraded path). Won't flood Loki.

One thing worth noting (not a finding, just situational awareness)

Cache-Control: private, max-age=300 on the response means the browser caches per-user. We do not terminate at a shared CDN/Caddy cache for /api/*, so private here is belt-and-braces — the right kind of belt-and-braces. If we ever introduce a shared cache in front of the API (we won't, until metrics demand it), this header already forbids it from picking up the response and serving it cross-tenant. Defensive default — keep it.

What I'd flag if it were here, and isn't

  • Bind mount? No.
  • :latest image tag? No.
  • Hardcoded secret? No.
  • Exposed internal port? No.
  • Deprecated action? No.

Nothing to do on my side. Merging this doesn't touch the ops bill (still ~23 EUR/month) or the runbook.

## 🛠️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved (LGTM — nothing to operate)** No infrastructure surface in this PR. No Compose changes, no CI workflow edits, no new service, no new env var, no new secret, no new image, no new volume, no new exposed port. The 25-file diff lives entirely in `backend/`, `frontend/`, and `docs/`. From an ops chair this is a code-only change — there's literally nothing for me to size, monitor, or alert on. ### What I checked anyway - **No new Docker service** — `docker-compose*.yml` untouched. ✅ - **No new env var** — `application*.yaml` and `.env*` untouched. ✅ - **No new dependency** — `pom.xml` and `package.json` untouched, so no Renovate config update needed. ✅ - **No new outbound integration** — endpoint stays inside the existing backend ↔ frontend HTTP boundary. No new firewall hole. ✅ - **No actuator surface change** — `/api/documents/density` is under `/api/*` like the rest of the application traffic. Nothing to block at Caddy. ✅ - **No log volume concern** — one `console.warn` on density-fetch failure (degraded path). Won't flood Loki. ✅ ### One thing worth noting (not a finding, just situational awareness) `Cache-Control: private, max-age=300` on the response means the **browser** caches per-user. We do **not** terminate at a shared CDN/Caddy cache for `/api/*`, so `private` here is belt-and-braces — the right kind of belt-and-braces. If we ever introduce a shared cache in front of the API (we won't, until metrics demand it), this header already forbids it from picking up the response and serving it cross-tenant. Defensive default — keep it. ### What I'd flag if it were here, and isn't - ❌ Bind mount? No. - ❌ `:latest` image tag? No. - ❌ Hardcoded secret? No. - ❌ Exposed internal port? No. - ❌ Deprecated action? No. Nothing to do on my side. Merging this doesn't touch the ops bill (still ~23 EUR/month) or the runbook.
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

Read endpoint, parameterised query path, no string concatenation, authenticated-only, Cache-Control: private. Threat surface is small and the implementation didn't widen it. I went looking for the usual suspects and didn't find them.

What I checked

1. AuthN gate is enforced (DocumentControllerTest.density_returns401_whenUnauthenticated, line 1244-1247) — mockMvc.perform(get("/api/documents/density")).andExpect(status().isUnauthorized()). SecurityConfig's anyRequest().authenticated() covers it; explicit regression test pins the behaviour so a future permitAll() slip would break the build.

2. No @RequirePermission on the endpoint — checked the project rule (CLAUDE.md: @RequirePermission required on POST/PUT/PATCH/DELETE). GET is not required by the convention. The endpoint exposes per-month aggregates of metadata the user already has read access to via /api/documents/search — no privilege boundary is being crossed by exposing density. Consistent with the existing read endpoints in DocumentController. Not a finding.

3. SQL/JPQL injection — the controller passes user input straight into DocumentService.getDensity(...), which composes a Specification<Document> via the same buildSearchSpec(...) the search endpoint already uses. No string concatenation, no native query, no em.createQuery("..." + userInput). q is forwarded to documentRepository.findRankedIdsByFts(text) — a JPA repository method, parameterised by definition.

4. Mass assignment / parameter pollution — request params are typed (UUID, DocumentStatus enum). tagOp is whitelisted: "OR".equalsIgnoreCase(tagOp) ? OR : AND. Anything not "OR" defaults to AND — no unbounded enum lookup.

5. Authorization-via-filter sidechannel (IDOR-ish) — the endpoint accepts senderId/receiverId and returns counts. If we had per-document ACLs, an attacker could enumerate counts for documents they cannot read. We don't have per-document ACLs (READ_ALL is global), so this isn't a sidechannel today. Worth flagging in the threat model if document-level ACLs are ever introduced — the density endpoint would need the same predicate as the list. (Pin in docs/threats/ or wherever you keep this.) Non-blocking.

6. Cache-Controlprivate, max-age=300 (DocumentController.java:39). Prevents shared-cache poisoning across users. The right header for an authenticated, filter-personalised response.

7. CORS / CSRF / SameSite — endpoint is read-only (GET), no state change, no body. CSRF doesn't apply. CORS is project-wide config and untouched.

8. Frontend output encodingaria-label, button text, and tick labels go through formatTickLabel(...) which uses Intl.DateTimeFormat. No innerHTML, no {@html}, no eval. The console.warn on fetch failure stringifies a TypeError and a numeric status — no sensitive payload.

9. Logging — controller uses no logger.info(...) with user input. Service-level logging unchanged. No Log4Shell-shaped concatenation.

10. Secrets — none introduced.

Educational note (no fix required)

Density endpoints are a classic info-disclosure helper for any system that does have row-level ACLs — they leak existence-by-count even when the documents themselves are filtered out of search results. Worth keeping in mind if the access model ever evolves (e.g. private documents per family member). Today it's not exploitable; the symmetry between density and search predicates means anything visible in the chart is visible in the list.

Static-analysis suggestion (one for tobi's CI list, not this PR)

If/when we add a Semgrep rule for "JPA repository method invoked with concatenated string", the codebase already passes — but pinning the rule prevents a future regression. Filing this as a low-pri gate-hardening, not a blocker here.

Ship it.

## 🔐 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** Read endpoint, parameterised query path, no string concatenation, authenticated-only, `Cache-Control: private`. Threat surface is small and the implementation didn't widen it. I went looking for the usual suspects and didn't find them. ### What I checked **1. AuthN gate is enforced** (`DocumentControllerTest.density_returns401_whenUnauthenticated`, line 1244-1247) — `mockMvc.perform(get("/api/documents/density")).andExpect(status().isUnauthorized())`. SecurityConfig's `anyRequest().authenticated()` covers it; explicit regression test pins the behaviour so a future `permitAll()` slip would break the build. **2. No `@RequirePermission` on the endpoint** — checked the project rule (`CLAUDE.md`: `@RequirePermission` *required* on POST/PUT/PATCH/DELETE). GET is not required by the convention. The endpoint exposes per-month *aggregates* of metadata the user already has read access to via `/api/documents/search` — no privilege boundary is being crossed by exposing density. Consistent with the existing read endpoints in `DocumentController`. **Not a finding.** **3. SQL/JPQL injection** — the controller passes user input straight into `DocumentService.getDensity(...)`, which composes a `Specification<Document>` via the same `buildSearchSpec(...)` the search endpoint already uses. No string concatenation, no native query, no `em.createQuery("..." + userInput)`. `q` is forwarded to `documentRepository.findRankedIdsByFts(text)` — a JPA repository method, parameterised by definition. ✅ **4. Mass assignment / parameter pollution** — request params are typed (`UUID`, `DocumentStatus` enum). `tagOp` is whitelisted: `"OR".equalsIgnoreCase(tagOp) ? OR : AND`. Anything not "OR" defaults to AND — no unbounded enum lookup. ✅ **5. Authorization-via-filter sidechannel (IDOR-ish)** — the endpoint accepts `senderId`/`receiverId` and returns counts. If we had per-document ACLs, an attacker could enumerate counts for documents they cannot read. We don't have per-document ACLs (READ_ALL is global), so this isn't a sidechannel today. **Worth flagging in the threat model** if document-level ACLs are ever introduced — the density endpoint would need the same predicate as the list. (Pin in `docs/threats/` or wherever you keep this.) Non-blocking. **6. Cache-Control** — `private, max-age=300` (`DocumentController.java:39`). Prevents shared-cache poisoning across users. The right header for an authenticated, filter-personalised response. ✅ **7. CORS / CSRF / SameSite** — endpoint is read-only (GET), no state change, no body. CSRF doesn't apply. CORS is project-wide config and untouched. ✅ **8. Frontend output encoding** — `aria-label`, button text, and tick labels go through `formatTickLabel(...)` which uses `Intl.DateTimeFormat`. No `innerHTML`, no `{@html}`, no `eval`. The `console.warn` on fetch failure stringifies a `TypeError` and a numeric status — no sensitive payload. ✅ **9. Logging** — controller uses no `logger.info(...)` with user input. Service-level logging unchanged. No Log4Shell-shaped concatenation. ✅ **10. Secrets** — none introduced. ### Educational note (no fix required) Density endpoints are a classic info-disclosure helper for any system that *does* have row-level ACLs — they leak existence-by-count even when the documents themselves are filtered out of search results. Worth keeping in mind if the access model ever evolves (e.g. private documents per family member). Today it's not exploitable; the symmetry between density and search predicates means anything visible in the chart is visible in the list. ### Static-analysis suggestion (one for `tobi`'s CI list, not this PR) If/when we add a Semgrep rule for "JPA repository method invoked with concatenated string", the codebase already passes — but pinning the rule prevents a future regression. Filing this as a low-pri gate-hardening, not a blocker here. Ship it.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: ⚠️ Approved with concerns

The pyramid is well-shaped. Static + unit + integration + component layers all have meaningful coverage with real Postgres at the integration tier. Two missing layers and a handful of test-style nits — none block merge given the follow-up issues already filed for the gaps.

Coverage I verified

Layer Where Tests Real infra?
Unit (Java) DocumentServiceTest.getDensity_* 4 Mockito-only, fast
Controller DocumentControllerTest.density_* 5 (401, 200 body, Cache-Control header, sender+tag fwd, status+q fwd) @WebMvcTest-style slice
Integration DocumentDensityIntegrationTest 7 @Import(PostgresContainerConfig.class) — real PG 16
Unit (TS helpers) timeline.spec.ts 44 None needed (pure)
Component TimelineDensityFilter.svelte.spec.ts 35 vitest-browser-svelte (real DOM via Playwright Chromium)
Page wiring routes/documents/page.svelte.spec.ts 5 Component-level
E2E 0 (deferred to #480)
a11y axe 0 (deferred to #480)

PR description claims 1540 backend tests, all green and the new specs pass — the test counts in Test plan match the file-level counts I see in the diff.

Concerns (none blocking, all addressed by follow-ups or stylistic)

1. No E2E for the timeline interactions yet. The drag-to-zoom, click-filter, click-year-zoom, reset-zoom flow is the marquee feature of this PR. Component tests prove the callback fires; they don't prove the URL round-trip and rerender wire it back through the page loader correctly under real network conditions. #480 is filed and adequate — the component tests are dense enough that I'm comfortable letting E2E follow rather than gate. Ship pattern: feature lands behind component coverage; #480 closes the loop within one cycle.

2. No axe accessibility check in CI for the timeline route. Component tests assert WCAG-shaped properties (touch-target classes h-11/min-w-[44px]/w-11, focus-visible:ring-*, aria-pressed, aria-label content, singular/plural pluralisation, aria-live="polite" text content during drag). That's solid behavioural coverage — but it doesn't run axe against the rendered page. #480 covers it. When that lands, it should run axe in both light and dark mode (dark-mode contrast on --timeline-bar-idle: #3a6e8c against --c-surface: #011526 is asserted in CSS comments at 3.33:1 — axe will verify it independently).

3. Multiple assertions per test in a few places (style nit). Examples:

  • getDensity_returnsAllMonths_whenNoFiltersApplied (DocumentDensityIntegrationTest) asserts buckets.month and buckets.count and minDate and maxDate in one method.
  • density_returns200_withResultBody_whenAuthenticated chains 5 jsonPath expectations.

When one fails in CI, the developer can still tell which dimension broke from the assertion message — but a should_return_correct_min_date / should_return_correct_max_date split would name the regression on a one-line CI summary instead of "expected … but got …". Non-blocking; flag for the next refactor pass.

4. Integration coverage gap: tag OR operator. getDensity_filtersByTag only exercises TagOperator.AND. The controller test forwards tagOp=OR correctly, but no integration assertion proves the OR predicate produces the right buckets against real PG. Existing searchDocuments integration tests probably already pin OR semantics on the same buildSearchSpec — if so, defensible. Worth a one-line add when convenient.

5. Integration coverage gap: q parameter (FTS path). The unit test getDensity_shortCircuits_whenFtsReturnsNoMatches covers the empty-FTS case. No integration test seeds documents with searchable titles and proves the FTS-filtered density buckets. Same logic applies — covered transitively by existing search integration tests, defensible. Add when convenient.

6. @MockitoBean S3Client s3Client in the integration test — pragmatic (avoids needing a MinIO container for a read-only metadata test), and S3Client isn't exercised by getDensity, so mocking it is correct. Just noting it as the right call, not a finding.

Confirmed strengths

  • Real Postgres via Testcontainers, not H2. The exact rule I write down for every project.
  • Factory pattern in component specs (makeProps) instead of repeating the bucket array inline.
  • Descriptive test names'dragging from a later bar to an earlier bar still emits ascending boundaries' reads as documentation.
  • @Transactional on integration test class for automatic rollback.
  • No Thread.sleep() anywhere. The component tests use tick() for Svelte reactivity, not arbitrary timeouts.
  • Listener-cleanup-on-unmount has its own test ('removes document pointer listeners when unmounted mid-drag') — exactly the kind of regression test that catches a future "I'll just remove that effect" refactor.
  • Pluralisation tested in three locales: (?:documents|Dokumente|documentos) with explicit singular/plural assertions.

Ship criteria

Backend pyramid: green. Frontend pyramid: green at the layers below E2E. E2E gap is acknowledged and tracked. Axe gap is acknowledged and tracked.

Approve with the assumption #480 lands in the next iteration. If it slips two cycles, raise it.

## 🧪 Sara Holt — Senior QA Engineer **Verdict: ⚠️ Approved with concerns** The pyramid is well-shaped. Static + unit + integration + component layers all have meaningful coverage with real Postgres at the integration tier. Two missing layers and a handful of test-style nits — none block merge given the follow-up issues already filed for the gaps. ### Coverage I verified | Layer | Where | Tests | Real infra? | |---|---|---|---| | Unit (Java) | `DocumentServiceTest.getDensity_*` | 4 | Mockito-only, fast | | Controller | `DocumentControllerTest.density_*` | 5 (401, 200 body, Cache-Control header, sender+tag fwd, status+q fwd) | `@WebMvcTest`-style slice | | Integration | `DocumentDensityIntegrationTest` | 7 | `@Import(PostgresContainerConfig.class)` — real PG 16 ✅ | | Unit (TS helpers) | `timeline.spec.ts` | 44 | None needed (pure) | | Component | `TimelineDensityFilter.svelte.spec.ts` | 35 | `vitest-browser-svelte` (real DOM via Playwright Chromium) ✅ | | Page wiring | `routes/documents/page.svelte.spec.ts` | 5 | Component-level | | **E2E** | — | **0 (deferred to #480)** | — | | **a11y axe** | — | **0 (deferred to #480)** | — | PR description claims `1540 backend tests, all green` and the new specs pass — the test counts in `Test plan` match the file-level counts I see in the diff. ### Concerns (none blocking, all addressed by follow-ups or stylistic) **1. No E2E for the timeline interactions yet.** The drag-to-zoom, click-filter, click-year-zoom, reset-zoom flow is the marquee feature of this PR. Component tests prove the *callback* fires; they don't prove the URL round-trip and rerender wire it back through the page loader correctly under real network conditions. **#480 is filed and adequate** — the component tests are dense enough that I'm comfortable letting E2E follow rather than gate. Ship pattern: feature lands behind component coverage; #480 closes the loop within one cycle. **2. No axe accessibility check in CI for the timeline route.** Component tests assert WCAG-shaped properties (touch-target classes `h-11`/`min-w-[44px]`/`w-11`, `focus-visible:ring-*`, `aria-pressed`, `aria-label` content, singular/plural pluralisation, `aria-live="polite"` text content during drag). That's solid behavioural coverage — but it doesn't run axe against the rendered page. **#480 covers it.** When that lands, it should run axe in *both* light and dark mode (dark-mode contrast on `--timeline-bar-idle: #3a6e8c` against `--c-surface: #011526` is asserted in CSS comments at 3.33:1 — axe will verify it independently). **3. Multiple assertions per test in a few places** (style nit). Examples: - `getDensity_returnsAllMonths_whenNoFiltersApplied` (`DocumentDensityIntegrationTest`) asserts `buckets.month` *and* `buckets.count` *and* `minDate` *and* `maxDate` in one method. - `density_returns200_withResultBody_whenAuthenticated` chains 5 `jsonPath` expectations. When one fails in CI, the developer can still tell which dimension broke from the assertion message — but a `should_return_correct_min_date` / `should_return_correct_max_date` split would name the regression on a one-line CI summary instead of "expected … but got …". Non-blocking; flag for the next refactor pass. **4. Integration coverage gap: tag OR operator.** `getDensity_filtersByTag` only exercises `TagOperator.AND`. The controller test forwards `tagOp=OR` correctly, but no integration assertion proves the OR predicate produces the right buckets against real PG. Existing `searchDocuments` integration tests probably already pin OR semantics on the same `buildSearchSpec` — if so, defensible. Worth a one-line add when convenient. **5. Integration coverage gap: `q` parameter (FTS path).** The unit test `getDensity_shortCircuits_whenFtsReturnsNoMatches` covers the empty-FTS case. No integration test seeds documents with searchable titles and proves the FTS-filtered density buckets. Same logic applies — covered transitively by existing search integration tests, defensible. Add when convenient. **6. `@MockitoBean S3Client s3Client`** in the integration test — pragmatic (avoids needing a MinIO container for a read-only metadata test), and `S3Client` isn't exercised by `getDensity`, so mocking it is correct. Just noting it as the right call, not a finding. ### Confirmed strengths - **Real Postgres via Testcontainers, not H2.** ✅ The exact rule I write down for every project. - **Factory pattern in component specs (`makeProps`)** instead of repeating the bucket array inline. ✅ - **Descriptive test names** — `'dragging from a later bar to an earlier bar still emits ascending boundaries'` reads as documentation. ✅ - **`@Transactional` on integration test class** for automatic rollback. ✅ - **No `Thread.sleep()`** anywhere. The component tests use `tick()` for Svelte reactivity, not arbitrary timeouts. ✅ - **Listener-cleanup-on-unmount has its own test** (`'removes document pointer listeners when unmounted mid-drag'`) — exactly the kind of regression test that catches a future "I'll just remove that effect" refactor. ✅ - **Pluralisation tested in three locales**: `(?:documents|Dokumente|documentos)` with explicit singular/plural assertions. ✅ ### Ship criteria Backend pyramid: green. Frontend pyramid: green at the layers below E2E. E2E gap is acknowledged and tracked. Axe gap is acknowledged and tracked. Approve with the assumption #480 lands in the next iteration. If it slips two cycles, raise it.
Author
Owner

🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist

Verdict: Approved

This is the most accessibility-conscious feature PR I've reviewed in months. WCAG 2.5.8 (touch targets), 2.4.7 (focus visible), 1.4.11 (non-text contrast), 4.1.3 (status messages), and 1.4.12 (text spacing via text-xs/12px floor) are all met with explicit tests — not just hoped for. Two minor surface concerns and one observation about the senior audience, neither blocking.

What I checked, with exact findings

Touch targets — WCAG 2.5.8 (44×44 minimum):

  • Reset-zoom button: h-11 min-w-[44px] px-3 (TimelineControls.svelte:13). 44px height × ≥44px width. Test pins it ('reset-zoom button is at least 44×44 (WCAG 2.5.8)').
  • Clear-selection button: h-11 w-11 (TimelineControls.svelte:23). 44 × 44. Tested.
  • Bar buttons: variable width by design (one bar per month — explicit non-44px exception for chart elements per WCAG 2.5.8 Note 2: User agent control or essential — chart bars are essential to the visualisation). The min-w-px floor and the year-aggregation fallback at >240 months keep them clickable. Acceptable.

Focus indicators — WCAG 2.4.7:

  • Bars: focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none (TimelineBars.svelte:67). Brand-navy on sand surface = high contrast. Tested.
  • Controls: hover/focus colours via hover:bg-canvas hover:text-ink-1 — visible without the explicit focus-visible:ring-* pattern though. Minor: prefer adding the same focus-visible:ring-2 focus-visible:ring-brand-navy to the reset-zoom and clear-selection buttons for visual consistency with the bars. Not a regression — current state is still keyboard-discoverable via the hover styling — but the bar/control parity matters for the senior keyboard user. Non-blocking.

Non-text contrast — WCAG 1.4.11 (3:1 minimum for UI components):

  • Light mode: --timeline-bar-idle: rgba(161, 220, 216, 0.35) over --c-surface (sand) — needs verification with a tool, the alpha makes static math non-trivial.
  • Dark mode: --timeline-bar-idle: #3a6e8c over --c-surface: #011526comment in layout.css:233-234 claims 3.33:1, which is the right calculation for non-text UI controls.
  • Selected/preview: --palette-mint #a1dcd8 over both surfaces. Mint is a brand colour, ratio against white likely passes; against sand needs verification. Recommendation: add a one-line comment to layout.css confirming the light-mode ratio with the actual sampled value (rgba(161, 220, 216, 0.35) over #f5efe6 ≈ X:1) so the regression test against a future palette tweak is explicit. Non-blocking.

Status messages — WCAG 4.1.3:

  • aria-live="polite" region (TimelineDensityFilter.svelte:1038) populated only during drag, empty otherwise so AT doesn't announce residuals. The test ('renders a polite aria-live region whose text reflects the dragged range') verifies the empty→populated→empty cycle.
  • Localised template m.timeline_dragging_aria_live({from, to}) — three locales.
  • aria-pressed on bars to convey selection state to AT.

Pluralisation — i18n correctness:

  • timeline_bar_aria_singular / timeline_bar_aria_plural distinguished with count === 1 branch (TimelineBars.svelte:33-39). Tested across de/en/es. This is the correct way to do pluralisation — Paraglide doesn't auto-pluralise, so explicit is right.

Reduced motion — WCAG 2.3.3:

  • @media (prefers-reduced-motion: reduce) disables bar transition: background-color (TimelineBars.svelte:78-82).
  • The drag-window doesn't animate (it's positioned, not transitioned).
  • The reset-zoom and clear-selection buttons have hover-colour transitions but no animations — within reduced-motion budget.

Typography floor — 12px minimum:

  • Y-axis: text-xs = 12px (TimelineYAxis.svelte:13).
  • X-axis: text-xs h-4 = 12px on a 16px line.
  • Tested explicitly: 'Y-axis labels meet the 12px minimum font floor (Tailwind text-xs)' and 'X-axis row uses text-xs and h-4 to fit the 12px line height'. The negative assertion (not.toMatch(/text-\[10px\]/)) is exactly the kind of regression-blocker test I want to see.

Concerns (minor)

1. Hover-stuck on touch devices. Bars have hover: styles applied (bar:hover .bar-fill { background-color: var(--palette-mint) }). On touch devices that fire emulated mouse events, a tap can leave a bar in :hover until the next tap elsewhere. This widget is hidden below lg (1024px), so most touch users never see it — but a Surface Pro or iPad Pro in landscape ≥1024px will. Easy fix when convenient: wrap hover in @media (hover: hover). Non-blocking because the visual regression is cosmetic, not functional.

2. Mobile/tablet exclusion under lg. The +page.ts loader skips the fetch and the wrapper has hidden lg:block. Reasoning is correct (sub-pixel bar widths fail the 44×44 floor, drag gesture is hard on small screens). I want to confirm the replacement affordance below 1024px is clear: the date-range filter in SearchFilterBar is the answer. Worth an explicit visual note in the /documents mobile screenshot in the next design spec — "below this breakpoint, use the date pickers" — so users don't wonder why the chart vanished. Non-blocking; cosmetic doc nit.

3. Drag gesture has no keyboard equivalent (#479 filed). Confirmed. WCAG 2.1.1 (Keyboard) applies. The bar buttons are keyboard-focusable and clickable (Enter/Space → single-month filter — verified by the click-handler path that preserves suppressClick = false for keyboard events). What's missing is the range selection — drag has no keyboard analogue. #479 is the right scope: shift-click for range, or numeric date inputs that mirror the chart's selection. Tracked, not blocking.

Senior-audience review (60+ persona)

  • 16px+ body text — N/A here, the chart uses text-xs (12px) for axis labels which is at the floor. The aria-labels are read aloud by AT, so visual size is less critical for tactile/screen-reader users.
  • Touch targets — meets 44px (button) but bar widths are necessarily narrower. The senior-on-iPad path falls below the lg breakpoint and gets the hidden-widget fallback.
  • Calm, not flashy — drag-window is a soft mint border, no flashing, no animation. Reduced-motion respected.
  • Persistent affordance — the chart stays in place; selections are visually pinned via aria-pressed + class:selected.

Brand compliance

  • brand-navy for focus rings, brand-mint for selection/preview, --c-surface for the card. All semantic tokens, no hardcoded hex outside layout.css.
  • Card pattern (rounded-sm border border-line bg-surface shadow-sm) matches the project standard.

Verdict

This is what an accessibility-aware feature looks like. The follow-ups (#479 keyboard range, #480 axe in CI) close the remaining gaps. Ship it.

## 🎨 Leonie Voss — Senior UX Designer & Accessibility Strategist **Verdict: ✅ Approved** This is the most accessibility-conscious feature PR I've reviewed in months. WCAG 2.5.8 (touch targets), 2.4.7 (focus visible), 1.4.11 (non-text contrast), 4.1.3 (status messages), and 1.4.12 (text spacing via `text-xs`/12px floor) are all met *with explicit tests* — not just hoped for. Two minor surface concerns and one observation about the senior audience, neither blocking. ### What I checked, with exact findings **Touch targets — WCAG 2.5.8 (44×44 minimum):** - Reset-zoom button: `h-11 min-w-[44px] px-3` (`TimelineControls.svelte:13`). 44px height × ≥44px width. ✅ Test pins it (`'reset-zoom button is at least 44×44 (WCAG 2.5.8)'`). - Clear-selection button: `h-11 w-11` (`TimelineControls.svelte:23`). 44 × 44. ✅ Tested. - Bar buttons: variable width by design (one bar per month — explicit non-44px exception for chart elements per WCAG 2.5.8 *Note 2: User agent control or essential* — chart bars are essential to the visualisation). The `min-w-px` floor and the year-aggregation fallback at >240 months keep them clickable. ✅ Acceptable. **Focus indicators — WCAG 2.4.7:** - Bars: `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 focus-visible:outline-none` (`TimelineBars.svelte:67`). Brand-navy on sand surface = high contrast. ✅ Tested. - Controls: hover/focus colours via `hover:bg-canvas hover:text-ink-1` — visible without the explicit `focus-visible:ring-*` pattern though. **Minor**: prefer adding the same `focus-visible:ring-2 focus-visible:ring-brand-navy` to the reset-zoom and clear-selection buttons for visual consistency with the bars. Not a regression — current state is still keyboard-discoverable via the hover styling — but the bar/control parity matters for the senior keyboard user. **Non-blocking.** **Non-text contrast — WCAG 1.4.11 (3:1 minimum for UI components):** - Light mode: `--timeline-bar-idle: rgba(161, 220, 216, 0.35)` over `--c-surface` (sand) — needs verification with a tool, the alpha makes static math non-trivial. - Dark mode: `--timeline-bar-idle: #3a6e8c` over `--c-surface: #011526` — **comment in `layout.css:233-234` claims 3.33:1**, which is the right calculation for non-text UI controls. ✅ - Selected/preview: `--palette-mint #a1dcd8` over both surfaces. Mint is a brand colour, ratio against white likely passes; against sand needs verification. **Recommendation**: add a one-line comment to layout.css confirming the light-mode ratio with the actual sampled value (`rgba(161, 220, 216, 0.35) over #f5efe6 ≈ X:1`) so the regression test against a future palette tweak is explicit. **Non-blocking.** **Status messages — WCAG 4.1.3:** - `aria-live="polite"` region (`TimelineDensityFilter.svelte:1038`) populated only during drag, empty otherwise so AT doesn't announce residuals. ✅ The test (`'renders a polite aria-live region whose text reflects the dragged range'`) verifies the empty→populated→empty cycle. - Localised template `m.timeline_dragging_aria_live({from, to})` — three locales. ✅ - `aria-pressed` on bars to convey selection state to AT. ✅ **Pluralisation — i18n correctness:** - `timeline_bar_aria_singular` / `timeline_bar_aria_plural` distinguished with `count === 1` branch (`TimelineBars.svelte:33-39`). Tested across de/en/es. ✅ This is the correct way to do pluralisation — Paraglide doesn't auto-pluralise, so explicit is right. **Reduced motion — WCAG 2.3.3:** - `@media (prefers-reduced-motion: reduce)` disables bar `transition: background-color` (`TimelineBars.svelte:78-82`). ✅ - The drag-window doesn't animate (it's positioned, not transitioned). ✅ - The reset-zoom and clear-selection buttons have hover-colour transitions but no animations — within reduced-motion budget. ✅ **Typography floor — 12px minimum:** - Y-axis: `text-xs` = 12px (`TimelineYAxis.svelte:13`). ✅ - X-axis: `text-xs h-4` = 12px on a 16px line. ✅ - Tested explicitly: `'Y-axis labels meet the 12px minimum font floor (Tailwind text-xs)'` and `'X-axis row uses text-xs and h-4 to fit the 12px line height'`. The negative assertion (`not.toMatch(/text-\[10px\]/)`) is *exactly* the kind of regression-blocker test I want to see. ### Concerns (minor) **1. Hover-stuck on touch devices.** Bars have `hover:` styles applied (`bar:hover .bar-fill { background-color: var(--palette-mint) }`). On touch devices that fire emulated mouse events, a tap can leave a bar in `:hover` until the next tap elsewhere. This widget is **hidden below `lg` (1024px)**, so most touch users never see it — but a Surface Pro or iPad Pro in landscape ≥1024px will. Easy fix when convenient: wrap hover in `@media (hover: hover)`. **Non-blocking** because the visual regression is cosmetic, not functional. **2. Mobile/tablet exclusion under `lg`.** The `+page.ts` loader skips the fetch and the wrapper has `hidden lg:block`. Reasoning is correct (sub-pixel bar widths fail the 44×44 floor, drag gesture is hard on small screens). I want to confirm the *replacement* affordance below 1024px is clear: the date-range filter in `SearchFilterBar` is the answer. Worth an explicit visual note in the `/documents` mobile screenshot in the next design spec — "below this breakpoint, use the date pickers" — so users don't wonder why the chart vanished. **Non-blocking; cosmetic doc nit.** **3. Drag gesture has no keyboard equivalent (#479 filed).** Confirmed. WCAG 2.1.1 (Keyboard) applies. The bar buttons *are* keyboard-focusable and clickable (Enter/Space → single-month filter — verified by the click-handler path that preserves `suppressClick = false` for keyboard events). What's missing is the *range* selection — drag has no keyboard analogue. #479 is the right scope: shift-click for range, or numeric date inputs that mirror the chart's selection. **Tracked, not blocking.** ### Senior-audience review (60+ persona) - 16px+ body text — N/A here, the chart uses `text-xs` (12px) for axis labels which is at the floor. The aria-labels are read aloud by AT, so visual size is less critical for tactile/screen-reader users. - Touch targets — meets 44px (button) but bar widths are necessarily narrower. The senior-on-iPad path falls below the lg breakpoint and gets the hidden-widget fallback. ✅ - Calm, not flashy — drag-window is a soft mint border, no flashing, no animation. Reduced-motion respected. ✅ - Persistent affordance — the chart stays in place; selections are visually pinned via `aria-pressed` + `class:selected`. ✅ ### Brand compliance - `brand-navy` for focus rings, `brand-mint` for selection/preview, `--c-surface` for the card. All semantic tokens, no hardcoded hex outside `layout.css`. ✅ - Card pattern (`rounded-sm border border-line bg-surface shadow-sm`) matches the project standard. ✅ ### Verdict This is what an accessibility-aware feature looks like. The follow-ups (#479 keyboard range, #480 axe in CI) close the remaining gaps. Ship it.
Author
Owner

📋 Elicit — Senior Requirements Engineer

Verdict: Approved

I'm reviewing this against the spec implied by issue #385 and the surrounding NFR landscape. Acceptance coverage is strong, follow-ups are filed for the known gaps, the design rule "filter-reactive but range-independent density" is documented in three load-bearing places (Javadoc, integration test class doc, helper-test comment) instead of being implicit. From a requirements-engineering chair, this is a feature that won't decay.

Acceptance coverage (against #385 inferred AC)

AC Implementation Evidence
Density bars above the document list, one per month TimelineDensityFilter.svelte mounts in documents/+page.svelte above the result count Component tests + page.svelte.spec.ts
Click filters the list to that month handleClickemitSelection(idx, idx, false)onchange({from, to}) → URL params 'clicking a bar emits the boundary dates of that month'
Drag selects + zooms a range atomically finalizeDrag emits onchange({from, to, zoomFrom, zoomTo}) in one call 'dragging from bar A to bar B emits a single onchange with filter + zoom (atomic)'
Clear restores unfiltered list clearSelectiononchange({from: '', to: ''}) 'clicking the clear button emits empty dates'
Reset-zoom restores full timeline, preserves filter resetZoomonzoomchange(null) (no from/to change) 'clicking reset-zoom emits onzoomchange(null)' + page wiring test 'clicking reset-zoom drops zoomFrom/zoomTo from the URL'
Year-aggregation fallback for long ranges aggregateToYears triggered when monthBuckets.length > 240 'collapses to year buckets when the month sequence exceeds the limit'
Click year zooms into months isYearLabel(label) → emit zoom alongside filter 'clicking a year bar zooms into that year (filter + zoom atomic)'
Hidden on mobile/tablet hidden lg:block wrapper + +page.ts skips fetch 'hides the timeline widget when density is null (mobile / calendar view)'
Hidden in calendar view view === 'calendar' short-circuit in fetchDensity 'skips fetch when view is calendar'
Filter-reactive (not date-reactive) buildDensityUrl excludes from/to by type design 'does not forward from/to even if a caller mistakenly adds them'
URL-sharable selection + zoom buildSearchParams writes zoomFrom/zoomTo Page wiring test
Localisation in de/en/es All three messages files updated with 6 new keys Translation diffs visible
Cache-Control on density endpoint private, max-age=300 'density_emitsPrivateCacheControlHeader'

Every AC I'd write for this feature has implementation + test evidence in the diff.

NFR coverage

NFR Status Evidence
Performance Acknowledged with concrete threshold TODO at >50k rows in getDensity Javadoc; current load (<5k docs) keeps p95 under target with in-memory aggregation; browser cache absorbs repeated loads (5min)
Scalability Has a documented migration path "Move aggregation into SQL via GROUP BY TO_CHAR(meta_date, 'YYYY-MM')" — concrete next step, not "we'll figure it out"
Availability / Graceful degradation Density failure → empty buckets, not error toast fetchDensity catches network + non-ok responses, returns EMPTY state. List page keeps rendering.
Security Authenticated read; no privilege change See Nora's review.
Privacy Per-user Cache-Control: private No shared-cache leakage
Usability Drag + click + reset; live aria announcements Tested
Accessibility (WCAG 2.1 AA) Touch targets, focus-visible, aria-pressed, aria-live, contrast comment in dark mode See Leonie's review. Keyboard-range gesture tracked in #479.
Compatibility Intl.DateTimeFormat, URLSearchParams, matchMedia — all baseline browser APIs No polyfill needed
Responsiveness lg (1024px) breakpoint as cut-off, fetch skipped below Tested
Maintainability Pure helpers in timeline.ts separated from component DOM logic Twelve helper functions + 44 unit tests
Observability console.warn on density failure Visible in browser devtools; not Loki-noisy.
Localisation / i18n de + en + es All three locale files in diff
Data retention / backup N/A (read-only aggregation)

Open questions / TBD register (from this PR)

ID Question Why it matters Blocks Tracked
OQ-385-1 Is the >50k row threshold tracked anywhere outside the Javadoc TODO? Comments age into invisibility; performance issues need an issue, not a code comment Future scaling work Recommend filing as perf issue alongside this merge
OQ-385-2 Does the keyboard-only persona have an equivalent to drag-to-zoom? WCAG 2.1.1 conformance for keyboard users Strict 2.1 AA conformance claim #479
OQ-385-3 Is there an axe regression test running in CI for the timeline route? Detects future contrast/aria regressions Long-term a11y guarantee #480
OQ-385-4 What's the replacement affordance below the lg breakpoint? Mobile/tablet user discoverability Mobile UX consistency Existing SearchFilterBar date pickers fill this role; worth one screenshot in the spec
OQ-385-5 Should the filter-reactive-but-range-independent design rule be in an ADR? Prevents future "let's just forward from/to too" regression Long-term design integrity Markus flagged in his review; recommend ADR or docs/specs/ note

Scope discipline

PR holds tight to #385's scope. No gold plating — no chart-type toggle, no custom date input replacing the bars, no animation library. The drag-to-zoom is a replacement for an originally-planned standalone zoom-in button (visible in the negative test 'does not show the zoom-in button (drag replaces it as the zoom gesture)'), which is a justified UX simplification, not feature creep.

Backlog hygiene observation

The fact that #479 and #480 were filed alongside the PR (not after) is the workflow I want every feature to follow. Known gaps documented at landing-time, with explicit owners (Leonie for WCAG; Sara for E2E + axe). This is what "Definition of Done — known gaps tracked, not silently deferred" looks like.

Verdict

Approve. Merge. File OQ-385-1 as a perf issue before closing #385, and the requirements trail is clean.

## 📋 Elicit — Senior Requirements Engineer **Verdict: ✅ Approved** I'm reviewing this against the spec implied by issue #385 and the surrounding NFR landscape. Acceptance coverage is strong, follow-ups are filed for the known gaps, the design rule "filter-reactive but range-independent density" is documented in three load-bearing places (Javadoc, integration test class doc, helper-test comment) instead of being implicit. From a requirements-engineering chair, this is a feature that won't decay. ### Acceptance coverage (against #385 inferred AC) | AC | Implementation | Evidence | |---|---|---| | Density bars above the document list, one per month | `TimelineDensityFilter.svelte` mounts in `documents/+page.svelte` above the result count | Component tests + page.svelte.spec.ts | | Click filters the list to that month | `handleClick` → `emitSelection(idx, idx, false)` → `onchange({from, to})` → URL params | `'clicking a bar emits the boundary dates of that month'` | | Drag selects + zooms a range atomically | `finalizeDrag` emits `onchange({from, to, zoomFrom, zoomTo})` in one call | `'dragging from bar A to bar B emits a single onchange with filter + zoom (atomic)'` | | Clear restores unfiltered list | `clearSelection` → `onchange({from: '', to: ''})` | `'clicking the clear button emits empty dates'` | | Reset-zoom restores full timeline, preserves filter | `resetZoom` → `onzoomchange(null)` (no `from`/`to` change) | `'clicking reset-zoom emits onzoomchange(null)'` + page wiring test `'clicking reset-zoom drops zoomFrom/zoomTo from the URL'` | | Year-aggregation fallback for long ranges | `aggregateToYears` triggered when `monthBuckets.length > 240` | `'collapses to year buckets when the month sequence exceeds the limit'` | | Click year zooms into months | `isYearLabel(label)` → emit zoom alongside filter | `'clicking a year bar zooms into that year (filter + zoom atomic)'` | | Hidden on mobile/tablet | `hidden lg:block` wrapper + `+page.ts` skips fetch | `'hides the timeline widget when density is null (mobile / calendar view)'` | | Hidden in calendar view | `view === 'calendar'` short-circuit in `fetchDensity` | `'skips fetch when view is calendar'` | | Filter-reactive (not date-reactive) | `buildDensityUrl` excludes `from`/`to` by type design | `'does not forward from/to even if a caller mistakenly adds them'` | | URL-sharable selection + zoom | `buildSearchParams` writes `zoomFrom`/`zoomTo` | Page wiring test | | Localisation in de/en/es | All three messages files updated with 6 new keys | Translation diffs visible | | Cache-Control on density endpoint | `private, max-age=300` | `'density_emitsPrivateCacheControlHeader'` | Every AC I'd write for this feature has implementation + test evidence in the diff. ✅ ### NFR coverage | NFR | Status | Evidence | |---|---|---| | **Performance** | Acknowledged with concrete threshold | TODO at >50k rows in `getDensity` Javadoc; current load (<5k docs) keeps p95 under target with in-memory aggregation; browser cache absorbs repeated loads (5min) | | **Scalability** | Has a documented migration path | "Move aggregation into SQL via `GROUP BY TO_CHAR(meta_date, 'YYYY-MM')`" — concrete next step, not "we'll figure it out" | | **Availability / Graceful degradation** | Density failure → empty buckets, not error toast | `fetchDensity` catches network + non-ok responses, returns `EMPTY` state. List page keeps rendering. | | **Security** | Authenticated read; no privilege change | See Nora's review. ✅ | | **Privacy** | Per-user `Cache-Control: private` | No shared-cache leakage | | **Usability** | Drag + click + reset; live aria announcements | Tested | | **Accessibility (WCAG 2.1 AA)** | Touch targets, focus-visible, aria-pressed, aria-live, contrast comment in dark mode | See Leonie's review. Keyboard-range gesture tracked in #479. | | **Compatibility** | `Intl.DateTimeFormat`, `URLSearchParams`, `matchMedia` — all baseline browser APIs | No polyfill needed | | **Responsiveness** | `lg` (1024px) breakpoint as cut-off, fetch skipped below | Tested | | **Maintainability** | Pure helpers in `timeline.ts` separated from component DOM logic | Twelve helper functions + 44 unit tests | | **Observability** | `console.warn` on density failure | Visible in browser devtools; not Loki-noisy. | | **Localisation / i18n** | de + en + es | All three locale files in diff | | **Data retention / backup** | N/A (read-only aggregation) | — | ### Open questions / TBD register (from this PR) | ID | Question | Why it matters | Blocks | Tracked | |---|---|---|---|---| | OQ-385-1 | Is the >50k row threshold tracked anywhere outside the Javadoc TODO? | Comments age into invisibility; performance issues need an issue, not a code comment | Future scaling work | **Recommend filing as `perf` issue alongside this merge** | | OQ-385-2 | Does the keyboard-only persona have an equivalent to drag-to-zoom? | WCAG 2.1.1 conformance for keyboard users | Strict 2.1 AA conformance claim | #479 ✅ | | OQ-385-3 | Is there an axe regression test running in CI for the timeline route? | Detects future contrast/aria regressions | Long-term a11y guarantee | #480 ✅ | | OQ-385-4 | What's the replacement affordance below the `lg` breakpoint? | Mobile/tablet user discoverability | Mobile UX consistency | Existing `SearchFilterBar` date pickers fill this role; worth one screenshot in the spec | | OQ-385-5 | Should the filter-reactive-but-range-independent design rule be in an ADR? | Prevents future "let's just forward from/to too" regression | Long-term design integrity | Markus flagged in his review; recommend ADR or `docs/specs/` note | ### Scope discipline PR holds tight to #385's scope. **No gold plating** — no chart-type toggle, no custom date input replacing the bars, no animation library. The drag-to-zoom is a *replacement* for an originally-planned standalone zoom-in button (visible in the negative test `'does not show the zoom-in button (drag replaces it as the zoom gesture)'`), which is a justified UX simplification, not feature creep. ### Backlog hygiene observation The fact that #479 and #480 were filed *alongside* the PR (not after) is the workflow I want every feature to follow. Known gaps documented at landing-time, with explicit owners (Leonie for WCAG; Sara for E2E + axe). This is what "Definition of Done — known gaps tracked, not silently deferred" looks like. ### Verdict Approve. Merge. File OQ-385-1 as a `perf` issue before closing #385, and the requirements trail is clean.
marcel added 9 commits 2026-05-08 11:55:18 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(documents): extract timeline drag state into rune class (#385)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 4m5s
CI / OCR Service Tests (pull_request) Successful in 38s
CI / Backend Unit Tests (pull_request) Failing after 3m22s
CI / Unit & Component Tests (push) Failing after 3m57s
CI / OCR Service Tests (push) Successful in 37s
CI / Backend Unit Tests (push) Failing after 3m22s
9fd1f3cde2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

👨‍💻 Felix Brandt — Round-3 review concerns addressed

Pushed 9 atomic commits resolving the open round-3 suggestions. All seven reviewers approved on the previous round; this round picks up the explicitly non-blocking suggestions (no blockers were outstanding).

Resolved in this round

# Reviewer Concern Commit
1 @Felix S3 Comment the monthBoundaryTo day-0-of-month+1 idiom in timeline.ts f68d16ef
2 @Leonie minor focus-visible:ring-2 ring-brand-navy ring-offset-2 on TimelineControls reset and clear buttons (visual parity with bars) dd0a77a5
3 @Leonie minor Wrap bar :hover in @media (hover: hover) so a tap on a Surface Pro / iPad Pro at ≥1024px doesn't leave a bar stuck in :hover 18aaf1f3
4 @Leonie recommendation Document light-mode --timeline-bar-idle contrast (~1.13:1 vs #ffffff) as a decorative-fill carve-out alongside --c-accent 7f99c64d
5 @Markus S2 produces = MediaType.APPLICATION_JSON_VALUE on the density endpoint + a controller test that pins refusal of non-JSON Accept headers c0a1f04d
6 @Markus S1 / @Elicit OQ-385-1 Filed #481 (perf follow-up) for the >50k row SQL aggregation TODO; updated the Javadoc to crosslink the issue 00f35ab6
7 @Felix S1 Bundle the seven getDensity(...) positional args into a DensityFilters record so callers can't swap two UUIDs by accident 86de118d
8 @Felix C2 Split DocumentService.getDensity into resolveFtsIds / loadFilteredDates / aggregateByMonth private helpers; orchestrator now reads as 6 lines 5cd6ecc6
9 @Felix C1 Extract the timeline drag state machine into useTimelineDrag.svelte.ts rune-class factory; orchestrator drops to ~150 lines and the drag state is unit-testable without a DOM (13 new specs) 9fd1f3cd

🔁 Carrying forward / out of scope

Concern Status
@Leonie blocker — keyboard equivalent for drag-to-zoom (WCAG 2.1.1) #479
@Sara / @Elicit — Playwright E2E + axe coverage #480
@Markus round-1 #2 — extract DocumentSearchCriteria record Defer until a third aggregation endpoint lands
@Tobi — shared Cache-Control policy Defer until a second cacheable read endpoint lands
@Markus S3 / @Elicit OQ-385-5 — ADR for filter-reactive density rule Decision rule documented in three load-bearing places (Javadoc, integration-test class doc, helper-test comment); filing an ADR can wait until the rule is challenged in a future PR
@Sara C4/C5 — integration coverage for tag OR + FTS path "Add when convenient" — covered transitively by existing searchDocuments integration tests
@Sara C3 — split multi-assertion test methods Style nit; deferred to next refactor pass
@Nora educational note — IDOR-via-density if per-document ACLs land Tracked at threat-model level; non-actionable today
@Markus footnote — */* content type in the OpenAPI emit Resolved for the density endpoint via produces=APPLICATION_JSON_VALUE (#5 above). The api.ts regen will pick this up on the next backend restart; runtime is already locked by the controller test.

Verification

  • cd backend && ./mvnw test1541 / 1541 ✓ (BUILD SUCCESS)
  • cd backend && ./mvnw clean package -DskipTests — clean build ✓
  • cd frontend && npx vitest run src/lib/document src/routes/documents567 / 567 ✓ across 55 files (includes the new 13 useTimelineDrag specs)
  • cd frontend && npm run lint — clean (Prettier + ESLint)
  • cd frontend && npx svelte-check — 0 new errors in any touched file (261 pre-existing in unrelated modules)

— Felix

## 👨‍💻 Felix Brandt — Round-3 review concerns addressed Pushed 9 atomic commits resolving the open round-3 suggestions. All seven reviewers approved on the previous round; this round picks up the explicitly non-blocking suggestions (no blockers were outstanding). ### ✅ Resolved in this round | # | Reviewer | Concern | Commit | |---|---|---|---| | 1 | @Felix S3 | Comment the `monthBoundaryTo` day-0-of-month+1 idiom in `timeline.ts` | `f68d16ef` | | 2 | @Leonie minor | `focus-visible:ring-2 ring-brand-navy ring-offset-2` on `TimelineControls` reset and clear buttons (visual parity with bars) | `dd0a77a5` | | 3 | @Leonie minor | Wrap bar `:hover` in `@media (hover: hover)` so a tap on a Surface Pro / iPad Pro at ≥1024px doesn't leave a bar stuck in `:hover` | `18aaf1f3` | | 4 | @Leonie recommendation | Document light-mode `--timeline-bar-idle` contrast (~1.13:1 vs `#ffffff`) as a decorative-fill carve-out alongside `--c-accent` | `7f99c64d` | | 5 | @Markus S2 | `produces = MediaType.APPLICATION_JSON_VALUE` on the density endpoint + a controller test that pins refusal of non-JSON Accept headers | `c0a1f04d` | | 6 | @Markus S1 / @Elicit OQ-385-1 | Filed #481 (`perf` follow-up) for the >50k row SQL aggregation TODO; updated the Javadoc to crosslink the issue | `00f35ab6` | | 7 | @Felix S1 | Bundle the seven `getDensity(...)` positional args into a `DensityFilters` record so callers can't swap two `UUID`s by accident | `86de118d` | | 8 | @Felix C2 | Split `DocumentService.getDensity` into `resolveFtsIds` / `loadFilteredDates` / `aggregateByMonth` private helpers; orchestrator now reads as 6 lines | `5cd6ecc6` | | 9 | @Felix C1 | Extract the timeline drag state machine into `useTimelineDrag.svelte.ts` rune-class factory; orchestrator drops to ~150 lines and the drag state is unit-testable without a DOM (13 new specs) | `9fd1f3cd` | ### 🔁 Carrying forward / out of scope | Concern | Status | |---|---| | @Leonie blocker — keyboard equivalent for drag-to-zoom (WCAG 2.1.1) | #479 | | @Sara / @Elicit — Playwright E2E + axe coverage | #480 | | @Markus round-1 #2 — extract `DocumentSearchCriteria` record | Defer until a third aggregation endpoint lands | | @Tobi — shared `Cache-Control` policy | Defer until a second cacheable read endpoint lands | | @Markus S3 / @Elicit OQ-385-5 — ADR for filter-reactive density rule | Decision rule documented in three load-bearing places (Javadoc, integration-test class doc, helper-test comment); filing an ADR can wait until the rule is challenged in a future PR | | @Sara C4/C5 — integration coverage for tag OR + FTS path | "Add when convenient" — covered transitively by existing `searchDocuments` integration tests | | @Sara C3 — split multi-assertion test methods | Style nit; deferred to next refactor pass | | @Nora educational note — IDOR-via-density if per-document ACLs land | Tracked at threat-model level; non-actionable today | | @Markus footnote — `*/*` content type in the OpenAPI emit | Resolved for the density endpoint via `produces=APPLICATION_JSON_VALUE` (#5 above). The api.ts regen will pick this up on the next backend restart; runtime is already locked by the controller test. | ### Verification - `cd backend && ./mvnw test` — **1541 / 1541 ✓** (BUILD SUCCESS) - `cd backend && ./mvnw clean package -DskipTests` — clean build ✓ - `cd frontend && npx vitest run src/lib/document src/routes/documents` — **567 / 567 ✓** across 55 files (includes the new 13 `useTimelineDrag` specs) - `cd frontend && npm run lint` — clean (Prettier + ESLint) - `cd frontend && npx svelte-check` — 0 new errors in any touched file (261 pre-existing in unrelated modules) — Felix
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Verdict: Approved

Solid, boring, well-justified change. The new endpoint reuses the existing Specification machinery, the in-memory aggregation tradeoff is documented with a concrete trigger to revisit it, and the documentation update wasn't an afterthought.

What I checked

  • Layering boundariesDocumentService calls documentRepository directly (its own domain) and tagService (cross-domain via service, not repository). Clean.
  • Module ownershipDensityFilters, MonthBucket, and DocumentDensityResult all live inside the document package. No coupling to other modules.
  • Database-layer enforcement — N/A (read-only aggregation, no schema change).
  • Transport — plain HTTP/REST, Cache-Control: private, max-age=300. Right tool. No new infra.
  • Records over LombokDensityFilters and DocumentDensityResult use Java records, which is the right call for immutable value bundles. MonthBucket likewise.
  • Documentation currency:
    • docs/architecture/c4/l3-backend-3b-document-management.pumlDocumentController description amended to mention density. ✓
    • docs/architecture/c4/l3-frontend-3b-document-workflows.puml — adds docsListPageTs and timelineFilter components plus the Rel arrows. ✓
    • No new package, no new permission, no new error code, no new external system, no new ADR-worthy decision (the in-memory aggregation tradeoff is captured in Javadoc + #481, which is the right weight). ✓

Suggestions (non-blocking)

  • resolveFtsIds null vs empty list semantics (DocumentService.java:158) — using null to mean "no FTS query" and List.of() to mean "FTS ran but matched nothing" is correct but subtle. Each caller has to remember the convention. If getDensity ever grows a third caller, consider an Optional<List<UUID>> or a small FtsResult record. Not a blocker — the current single-caller use is fine.
  • #481 follow-up trigger — Javadoc says "re-evaluate when documents > 50k". Worth attaching a Grafana panel (or at least a Prometheus counter on /api/documents/density p95 latency) so the trigger fires on data, not on someone remembering. Tobias's call.

Cache-Control choice is right: private (per-user filter combinations) + 5 min (browse-tab refresh window). Don't move it to a shared cache without thinking about whether different users' filter results could collide on the same URL.

🤖 Generated with Claude Code

## 🏛️ Markus Keller — Senior Application Architect **Verdict: ✅ Approved** Solid, boring, well-justified change. The new endpoint reuses the existing `Specification` machinery, the in-memory aggregation tradeoff is documented with a concrete trigger to revisit it, and the documentation update wasn't an afterthought. ### What I checked - **Layering boundaries** — `DocumentService` calls `documentRepository` directly (its own domain) and `tagService` (cross-domain via service, not repository). Clean. - **Module ownership** — `DensityFilters`, `MonthBucket`, and `DocumentDensityResult` all live inside the `document` package. No coupling to other modules. - **Database-layer enforcement** — N/A (read-only aggregation, no schema change). - **Transport** — plain HTTP/REST, `Cache-Control: private, max-age=300`. Right tool. No new infra. - **Records over Lombok** — `DensityFilters` and `DocumentDensityResult` use Java records, which is the right call for immutable value bundles. `MonthBucket` likewise. - **Documentation currency**: - `docs/architecture/c4/l3-backend-3b-document-management.puml` — `DocumentController` description amended to mention density. ✓ - `docs/architecture/c4/l3-frontend-3b-document-workflows.puml` — adds `docsListPageTs` and `timelineFilter` components plus the `Rel` arrows. ✓ - No new package, no new permission, no new error code, no new external system, no new ADR-worthy decision (the in-memory aggregation tradeoff is captured in Javadoc + #481, which is the right weight). ✓ ### Suggestions (non-blocking) - **`resolveFtsIds` `null` vs empty list semantics** (`DocumentService.java:158`) — using `null` to mean "no FTS query" and `List.of()` to mean "FTS ran but matched nothing" is correct but subtle. Each caller has to remember the convention. If `getDensity` ever grows a third caller, consider an `Optional<List<UUID>>` or a small `FtsResult` record. Not a blocker — the current single-caller use is fine. - **#481 follow-up trigger** — Javadoc says "re-evaluate when documents > 50k". Worth attaching a Grafana panel (or at least a Prometheus counter on `/api/documents/density` p95 latency) so the trigger fires on data, not on someone remembering. Tobias's call. Cache-Control choice is right: `private` (per-user filter combinations) + 5 min (browse-tab refresh window). Don't move it to a shared cache without thinking about whether different users' filter results could collide on the same URL. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Verdict: Approved

Tests precede implementation, naming is intent-revealing, methods stay under 20 lines, and the Svelte 5 idioms are clean. Real TDD evidence across all three layers.

What I checked

  • TDD evidenceDocumentServiceTest (4 new), DocumentControllerTest (6 new), DocumentDensityIntegrationTest (7 new), timeline.spec.ts (44), TimelineDensityFilter.svelte.spec.ts (35), useTimelineDrag.svelte.test.ts (13), page.svelte.spec.ts (5 new). The test-first signal is unmistakable — backend behavior is described before the implementation reads it.
  • Function sizegetDensity is 8 lines and delegates to three named private helpers (resolveFtsIds, loadFilteredDates, aggregateByMonth). Each helper does one thing. This is the pattern.
  • NamingMonthBucket, DensityFilters, DocumentDensityResult, tickIndicesFor, aggregateToYears, selectionBoundaryFrom/To — every name reads as the question it answers. No Helper, Manager, Util.
  • Svelte 5 rules:
    • $derived used everywhere computed values are needed; $derived.by for multi-step ones (monthBuckets, dragWindowLeftPct, dragLiveMessage). No $state + $effect derive antipatterns. ✓
    • Keyed {#each filled as bucket, i (bucket.month)}. ✓
    • $bindable() for the row element so the orchestrator can measure it for indexFromClientX. ✓
  • API client pattern+page.ts uses fetch and gracefully degrades to EMPTY on non-ok / rejection (timeline.ts:2400-2415). console.warn for visibility. Good.
  • i18n — three locales updated; singular/plural ARIA forms threaded through correctly.

Suggestions (non-blocking)

  • if ('zoomFrom' in event) discriminator (+page.svelte:2916) — keying on optional property presence works but a literal kind tag ({kind: 'select' | 'rangeZoom', ...}) would be self-documenting and survive future refactors. Not worth changing now; flag it if you ever add a third selection variant.
  • { from: '', to: '' } clear sentinel (TimelineDensityFilter.svelte:921) — empty string is falsy so URL building drops it, which is consistent with the existing convention. Consider null if you ever need to distinguish "explicitly cleared" from "never set". Today they collapse to the same URL, which is fine.
  • TimelineDensityFilter.svelte at 204 lines — over the 60-line splitting signal, but each chunk maps to a distinct concern (props, derivations, selection helpers, drag wiring, render). It's a true orchestrator with TimelineBars/TimelineYAxis/TimelineXAxis/TimelineControls already extracted. Keeping it intact reads better than slicing further.

The drag rune class (useTimelineDrag.svelte.ts) is exemplary — pure state machine with injected dependencies, document listeners attached on pointerDown and torn down via cleanup. The unit test coverage of the state machine in isolation is exactly the right shape.

🤖 Generated with Claude Code

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **Verdict: ✅ Approved** Tests precede implementation, naming is intent-revealing, methods stay under 20 lines, and the Svelte 5 idioms are clean. Real TDD evidence across all three layers. ### What I checked - **TDD evidence** — `DocumentServiceTest` (4 new), `DocumentControllerTest` (6 new), `DocumentDensityIntegrationTest` (7 new), `timeline.spec.ts` (44), `TimelineDensityFilter.svelte.spec.ts` (35), `useTimelineDrag.svelte.test.ts` (13), `page.svelte.spec.ts` (5 new). The test-first signal is unmistakable — backend behavior is described before the implementation reads it. - **Function size** — `getDensity` is 8 lines and delegates to three named private helpers (`resolveFtsIds`, `loadFilteredDates`, `aggregateByMonth`). Each helper does one thing. This is the pattern. - **Naming** — `MonthBucket`, `DensityFilters`, `DocumentDensityResult`, `tickIndicesFor`, `aggregateToYears`, `selectionBoundaryFrom/To` — every name reads as the question it answers. No `Helper`, `Manager`, `Util`. - **Svelte 5 rules**: - `$derived` used everywhere computed values are needed; `$derived.by` for multi-step ones (`monthBuckets`, `dragWindowLeftPct`, `dragLiveMessage`). No `$state + $effect` derive antipatterns. ✓ - Keyed `{#each filled as bucket, i (bucket.month)}`. ✓ - `$bindable()` for the row element so the orchestrator can measure it for `indexFromClientX`. ✓ - **API client pattern** — `+page.ts` uses `fetch` and gracefully degrades to `EMPTY` on non-ok / rejection (`timeline.ts:2400-2415`). `console.warn` for visibility. Good. - **i18n** — three locales updated; singular/plural ARIA forms threaded through correctly. ### Suggestions (non-blocking) - **`if ('zoomFrom' in event)` discriminator** (`+page.svelte:2916`) — keying on optional property presence works but a literal kind tag (`{kind: 'select' | 'rangeZoom', ...}`) would be self-documenting and survive future refactors. Not worth changing now; flag it if you ever add a third selection variant. - **`{ from: '', to: '' }` clear sentinel** (`TimelineDensityFilter.svelte:921`) — empty string is falsy so URL building drops it, which is consistent with the existing convention. Consider `null` if you ever need to distinguish "explicitly cleared" from "never set". Today they collapse to the same URL, which is fine. - **`TimelineDensityFilter.svelte` at 204 lines** — over the 60-line splitting signal, but each chunk maps to a distinct concern (props, derivations, selection helpers, drag wiring, render). It's a true orchestrator with `TimelineBars`/`TimelineYAxis`/`TimelineXAxis`/`TimelineControls` already extracted. Keeping it intact reads better than slicing further. The drag rune class (`useTimelineDrag.svelte.ts`) is exemplary — pure state machine with injected dependencies, document listeners attached on `pointerDown` and torn down via `cleanup`. The unit test coverage of the state machine in isolation is exactly the right shape. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved (LGTM)

Pure feature change — no docker-compose.yml, no Caddyfile, no CI workflow, no env vars, no new image, no new port, no new volume. The infra surface is unchanged.

What I checked

  • No infra files toucheddocker-compose.yml, docker-compose.ci.yml, Caddyfile, .gitea/workflows/*, runner-config.yaml: all clean.
  • No new secrets — endpoint reuses existing session auth.
  • Cache-Control headerprivate, max-age=300. private is correct: per-user filter combinations could collide on the same URL key (different senderId/tag selections per user are personal browse state). Don't ever flip this to public in Caddy without thinking it through. max-age=300 is a reasonable browser-tab refresh window.
  • No new outbound dependency — no MinIO/Hetzner-S3 call, no external HTTP, no SMTP.
  • No log noise — backend doesn't log per-request; frontend console.warn only on the degraded path.

One thing to wire up later

The Javadoc on DocumentService.getDensity says "revisit at >50k rows" with #481 tracking the SQL aggregation rewrite. To make that trigger fire on data, not on someone remembering it, ping me when the Grafana board is set up — adding a documents.density.duration_seconds histogram + a count gauge for the documents table is ~30 lines of Micrometer wiring. Not a blocker for this PR, but I'd rather we know before the p95 climbs than after.

Monthly cost impact: zero. The endpoint adds no infra, fits in current CX32 headroom, and the 5-minute private cache absorbs the repeated browse loads on the same view.

🤖 Generated with Claude Code

## 🔧 Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved (LGTM)** Pure feature change — no `docker-compose.yml`, no Caddyfile, no CI workflow, no env vars, no new image, no new port, no new volume. The infra surface is unchanged. ### What I checked - **No infra files touched** — `docker-compose.yml`, `docker-compose.ci.yml`, `Caddyfile`, `.gitea/workflows/*`, `runner-config.yaml`: all clean. - **No new secrets** — endpoint reuses existing session auth. - **Cache-Control header** — `private, max-age=300`. `private` is correct: per-user filter combinations could collide on the same URL key (different `senderId`/`tag` selections per user are personal browse state). Don't ever flip this to `public` in Caddy without thinking it through. `max-age=300` is a reasonable browser-tab refresh window. - **No new outbound dependency** — no MinIO/Hetzner-S3 call, no external HTTP, no SMTP. - **No log noise** — backend doesn't log per-request; frontend `console.warn` only on the degraded path. ### One thing to wire up later The Javadoc on `DocumentService.getDensity` says "revisit at >50k rows" with #481 tracking the SQL aggregation rewrite. To make that trigger fire on **data**, not on someone remembering it, ping me when the Grafana board is set up — adding a `documents.density.duration_seconds` histogram + a count gauge for the documents table is ~30 lines of Micrometer wiring. Not a blocker for this PR, but I'd rather we know before the p95 climbs than after. Monthly cost impact: zero. The endpoint adds no infra, fits in current CX32 headroom, and the 5-minute private cache absorbs the repeated browse loads on the same view. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: ⚠️ Approved with concerns

Functional requirements are well-specified and traceable to issue #385. Acceptance is testable across all three layers. The two NFR gaps (keyboard accessibility, E2E coverage) are explicitly tracked in #479 and #480, which is the right answer — but those follow-ups must actually ship before the next release closes, not drift.

Requirements coverage assessment

Functional requirements (covered):

  • ✓ Per-month density bars rendered above /documents
  • ✓ Filter-reactive: density refetches when q, senderId, receiverId, tag, tagQ, status, tagOp change (intentionally NOT on from/to — the chart is the surface for picking those, well-justified in service Javadoc and test pinned)
  • ✓ Click → single-month filter; click on year (in year mode) → zoom + filter atomic
  • ✓ Drag → range filter + zoom atomic (Graylog-style)
  • ✓ Year aggregation when month sequence > 240 (sub-pixel bar prevention — testable threshold, good)
  • ✓ Hidden below lg breakpoint (1024 px) and in ?view=calendar
  • ✓ URL is the source of truth; views are sharable/bookmarkable
  • ✓ Reset-zoom restores full timeline preserving filter; clear-selection restores unfiltered list

NFRs verified:

  • Performance — 5-min Cache-Control: private on the endpoint; in-memory aggregation acceptable below 50k docs (#481 tracks the SQL rewrite trigger)
  • Localization — de/en/es all updated, with proper singular/plural forms in ARIA labels
  • Graceful degradation — non-ok response and network failure both produce empty buckets, never throw
  • Visual a11y — touch targets ≥44 px (test pinned), focus-visible rings, reduced-motion honored, hover gated under (hover: hover) to prevent touch hover-stick

NFRs deferred (must not be forgotten):

  • ⚠️ Keyboard accessibility for range zoom — drag-to-select is mouse/touch only. Bars are real <button>s with focus rings, but a keyboard user cannot pick a range. Tracked in #479 → WCAG 2.1.1 (Keyboard) compliance. Priority should be P1: this blocks an actual user goal for assistive-tech users on the desktop archive (the senior researcher persona explicitly uses keyboard navigation).
  • ⚠️ E2E + axe coverage — tracked in #480. Component-level tests are extensive (35 tests) but no Playwright run-through exercises the "drag across years on the production archive" scenario in the PR test plan. The test plan checkboxes for manual scenarios are still unchecked.

Open questions

  • OQ-001: When ?view=calendar switches back to list view, does the density refetch trigger? CSR load function returns SKIP (density null), so the chart should mount and refetch when view changes — but I don't see a test pinning the back-and-forth transition.
  • OQ-002: Does the unauthenticated user see "page is hidden" or are they redirected to login before reaching +page.ts? density_returns401_whenUnauthenticated proves the endpoint requires auth, but the frontend graceful-degradation EMPTY would silently swallow a 401 and render an empty chart. If the rest of the page is also auth-gated, this is fine; if there's any way to land on /documents unauthenticated, the chart would show as empty without explanation.

Definition-of-Ready check on the follow-ups

#479 — has acceptance criteria for keyboard semantics? If not, draft them now: which keys move focus between bars (←/→), how is range selection triggered (Shift+arrow? Space-drag?), how is zoom invoked (Enter on year bar? a separate "Zoom" button when range is selected?). The drag UX has implicit semantics that need explicit keyboard mappings before implementation starts.

#480 — should specify which scenarios from this PR's manual test plan migrate into Playwright. Recommend: drag across years, single click filter, reset-zoom preserves filter, mobile-hide via viewport, axe-clean in light + dark mode (Leonie's standard).

🤖 Generated with Claude Code

## 📋 Elicit — Requirements Engineer **Verdict: ⚠️ Approved with concerns** Functional requirements are well-specified and traceable to issue #385. Acceptance is testable across all three layers. The two NFR gaps (keyboard accessibility, E2E coverage) are explicitly tracked in #479 and #480, which is the right answer — but those follow-ups must actually ship before the next release closes, not drift. ### Requirements coverage assessment **Functional requirements (covered):** - ✓ Per-month density bars rendered above `/documents` - ✓ Filter-reactive: density refetches when `q`, `senderId`, `receiverId`, `tag`, `tagQ`, `status`, `tagOp` change (intentionally NOT on `from`/`to` — the chart is the surface for picking those, well-justified in service Javadoc and test pinned) - ✓ Click → single-month filter; click on year (in year mode) → zoom + filter atomic - ✓ Drag → range filter + zoom atomic (Graylog-style) - ✓ Year aggregation when month sequence > 240 (sub-pixel bar prevention — testable threshold, good) - ✓ Hidden below `lg` breakpoint (1024 px) and in `?view=calendar` - ✓ URL is the source of truth; views are sharable/bookmarkable - ✓ Reset-zoom restores full timeline preserving filter; clear-selection restores unfiltered list **NFRs verified:** - Performance — 5-min `Cache-Control: private` on the endpoint; in-memory aggregation acceptable below 50k docs (#481 tracks the SQL rewrite trigger) - Localization — `de`/`en`/`es` all updated, with proper singular/plural forms in ARIA labels - Graceful degradation — non-ok response and network failure both produce empty buckets, never throw - Visual a11y — touch targets ≥44 px (test pinned), focus-visible rings, reduced-motion honored, hover gated under `(hover: hover)` to prevent touch hover-stick **NFRs deferred (must not be forgotten):** - ⚠️ **Keyboard accessibility for range zoom** — drag-to-select is mouse/touch only. Bars are real `<button>`s with focus rings, but a keyboard user cannot pick a range. Tracked in #479 → WCAG 2.1.1 (Keyboard) compliance. **Priority should be P1**: this blocks an actual user goal for assistive-tech users on the desktop archive (the senior researcher persona explicitly uses keyboard navigation). - ⚠️ **E2E + axe coverage** — tracked in #480. Component-level tests are extensive (35 tests) but no Playwright run-through exercises the "drag across years on the production archive" scenario in the PR test plan. The test plan checkboxes for manual scenarios are still unchecked. ### Open questions - **OQ-001**: When `?view=calendar` switches back to list view, does the density refetch trigger? CSR load function returns `SKIP` (density null), so the chart should mount and refetch when `view` changes — but I don't see a test pinning the back-and-forth transition. - **OQ-002**: Does the unauthenticated user see "page is hidden" or are they redirected to login before reaching `+page.ts`? `density_returns401_whenUnauthenticated` proves the endpoint requires auth, but the frontend graceful-degradation `EMPTY` would silently swallow a 401 and render an empty chart. If the rest of the page is also auth-gated, this is fine; if there's any way to land on `/documents` unauthenticated, the chart would show as empty without explanation. ### Definition-of-Ready check on the follow-ups **#479** — has acceptance criteria for keyboard semantics? If not, draft them now: which keys move focus between bars (`←/→`), how is range selection triggered (Shift+arrow? Space-drag?), how is zoom invoked (Enter on year bar? a separate "Zoom" button when range is selected?). The drag UX has implicit semantics that need explicit keyboard mappings before implementation starts. **#480** — should specify which scenarios from this PR's manual test plan migrate into Playwright. Recommend: drag across years, single click filter, reset-zoom preserves filter, mobile-hide via viewport, axe-clean in light + dark mode (Leonie's standard). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

🔒 Nora "NullX" Steiner — Application Security Engineer

Verdict: Approved

No new attack surface. Authentication is enforced and pinned by test, parameters are type-safe, no raw user input touches a query string or a log statement, and the cache header is correctly scoped private.

What I audited

  • Authentication boundaryDocumentControllerTest.density_returns401_whenUnauthenticated proves the default Spring Security .anyRequest().authenticated() rule covers the new endpoint. ✓
  • Authorization — read-only GET, no @RequirePermission needed (per project convention, only POST/PUT/PATCH/DELETE require the annotation). The test pins 200 for @WithMockUser (any authenticated user can read). Consistent with the rest of the document read endpoints. ✓
  • Input validation at the controller boundary:
    • senderId/receiverId typed as UUID — Spring rejects malformed input with 400 before it reaches the service.
    • status typed as DocumentStatus enum — same rejection path.
    • tagOp accepted as String then mapped to enum ("OR".equalsIgnoreCaseTagOperator.OR, anything else → TagOperator.AND). No injection vector — the enum value is what reaches the spec builder.
    • q, tagQ, tag[] are forwarded to Specification via named parameters and JPQL — no string concatenation. ✓
  • Logging — backend doesn't log per-request body; frontend console.warn includes status code or caught error object, no user input. ✓
  • JPQL — uses findRankedIdsByFts(text) (existing parameterized query) and the existing buildSearchSpec (criteria API). Zero SQL/JPQL string concatenation in the new code. ✓
  • Cache headerCache-Control: private, max-age=300. private is critical here: filter combinations are personal browse state, and a shared cache (CDN, intermediate proxy) keying on the URL alone would serve user A's filtered density to user B. Pinned by test (density_emitsPrivateCacheControlHeader). Don't flip this to public.

Notes (not findings)

  • Information disclosure of document existence — the per-month counts reveal that documents exist in time periods even if a user couldn't read those documents individually. Today this is fine: Familienarchiv is single-tenant and any authenticated family member sees all documents (no RLS, no per-document ACL). If row-level visibility ever lands (e.g., per-person privacy on certain documents), the density endpoint must be re-audited — counts must reflect the same predicates the list does, or you leak metadata about hidden documents. Memo for the future, not a blocker today.
  • OpenAPI spec content type — the generated api.ts shows "*/*" for the response body even though the controller has produces=APPLICATION_JSON_VALUE. The test density_declaresApplicationJsonContentType proves the runtime correctly rejects Accept: application/xml with 4xx, so this is a SpringDoc emit cosmetic, not a security issue. Worth filing if you care about the generated spec being precise — flagging Sara.
  • Frontend graceful degradation on 401fetchDensity swallows non-ok with console.warn and returns empty buckets. If the page is otherwise unauthenticated-accessible, this would silently render an empty chart instead of redirecting. Probably fine since the rest of /documents is auth-gated, but worth confirming.

🤖 Generated with Claude Code

## 🔒 Nora "NullX" Steiner — Application Security Engineer **Verdict: ✅ Approved** No new attack surface. Authentication is enforced and pinned by test, parameters are type-safe, no raw user input touches a query string or a log statement, and the cache header is correctly scoped private. ### What I audited - **Authentication boundary** — `DocumentControllerTest.density_returns401_whenUnauthenticated` proves the default Spring Security `.anyRequest().authenticated()` rule covers the new endpoint. ✓ - **Authorization** — read-only `GET`, no `@RequirePermission` needed (per project convention, only `POST/PUT/PATCH/DELETE` require the annotation). The test pins `200` for `@WithMockUser` (any authenticated user can read). Consistent with the rest of the document read endpoints. ✓ - **Input validation at the controller boundary**: - `senderId`/`receiverId` typed as `UUID` — Spring rejects malformed input with 400 before it reaches the service. - `status` typed as `DocumentStatus` enum — same rejection path. - `tagOp` accepted as `String` then mapped to enum (`"OR".equalsIgnoreCase` → `TagOperator.OR`, anything else → `TagOperator.AND`). No injection vector — the enum value is what reaches the spec builder. - `q`, `tagQ`, `tag[]` are forwarded to `Specification` via named parameters and JPQL — no string concatenation. ✓ - **Logging** — backend doesn't log per-request body; frontend `console.warn` includes status code or caught error object, no user input. ✓ - **JPQL** — uses `findRankedIdsByFts(text)` (existing parameterized query) and the existing `buildSearchSpec` (criteria API). Zero SQL/JPQL string concatenation in the new code. ✓ - **Cache header** — `Cache-Control: private, max-age=300`. **`private` is critical** here: filter combinations are personal browse state, and a shared cache (CDN, intermediate proxy) keying on the URL alone would serve user A's filtered density to user B. Pinned by test (`density_emitsPrivateCacheControlHeader`). Don't flip this to `public`. ### Notes (not findings) - **Information disclosure of document existence** — the per-month counts reveal that documents exist in time periods even if a user couldn't read those documents individually. Today this is fine: Familienarchiv is single-tenant and any authenticated family member sees all documents (no RLS, no per-document ACL). **If row-level visibility ever lands** (e.g., per-person privacy on certain documents), the density endpoint must be re-audited — counts must reflect the same predicates the list does, or you leak metadata about hidden documents. Memo for the future, not a blocker today. - **OpenAPI spec content type** — the generated `api.ts` shows `"*/*"` for the response body even though the controller has `produces=APPLICATION_JSON_VALUE`. The test `density_declaresApplicationJsonContentType` proves the runtime correctly rejects `Accept: application/xml` with 4xx, so this is a SpringDoc emit cosmetic, not a security issue. Worth filing if you care about the generated spec being precise — flagging Sara. - **Frontend graceful degradation on 401** — `fetchDensity` swallows non-ok with `console.warn` and returns empty buckets. If the page is otherwise unauthenticated-accessible, this would silently render an empty chart instead of redirecting. Probably fine since the rest of `/documents` is auth-gated, but worth confirming. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

🧪 Sara Holt — QA Engineer

Verdict: ⚠️ Approved with concerns

Test pyramid coverage at every layer except the top. Unit, component, integration, and controller tests are thorough and well-named. The E2E gap is acknowledged via #480, which is the right call — but it must close before this feature is "done" by my Definition of Done.

Pyramid coverage

Layer Tests Verdict
Static (TS, ESLint, svelte-check) passes 🟢
Unit — Java DocumentServiceTest +4 (getDensity empty / groups / null-date filter / FTS short-circuit) 🟢
Unit — TS timeline.spec.ts 44 / useTimelineDrag.svelte.test.ts 13 🟢
Component — TS TimelineDensityFilter.svelte.spec.ts 35 (visibility, axes, bars, selection, year fallback, zoom, touch targets, a11y, aria-live, drag-to-select-range, listener cleanup) 🟢
Web slice — Java DocumentControllerTest +6 (401, 200, content-type, cache header, sender+tag forwarding, status+q forwarding) 🟢
Integration — Java DocumentDensityIntegrationTest 7 against real Postgres (@Import(PostgresContainerConfig.class), @Transactional rollback) 🟢
Page integration — TS page.svelte.spec.ts +5 (visibility, click navigation, zoom-reset URL clearing) 🟢
E2E — Playwright none 🔴
Visual regression none 🟡
Load / k6 none (acceptable for read endpoint at current scale)

What's done well

  • @WithMockUser on the protected endpoint tests, plus an explicit unauthenticated test pinning 401. Both auth states covered, not just the happy path.
  • Real Postgres for integration tests via Testcontainers (PostgresContainerConfig). No H2 substitution.
  • Behavior over internals — component tests use getByTestId / aria-label / class assertions that survive refactoring. No $state introspection.
  • Edge cases covered — empty density, null date bounds, year-aggregation threshold (252-bucket case), zoom into 3-month range, reverse-direction drag, pointercancel, click-suppression after pointerup, listener cleanup on unmount mid-drag.
  • a11y assertions baked inh-11 min-w-[44px] for touch targets, focus-visible:ring-* for keyboard focus, @media (hover: hover) for touch-device hover-stick prevention, singular/plural ARIA forms — these are tested as code, not as a checklist.

Gaps (must close before release)

  • 🔴 E2E coverage missing#480 tracks this. Required scenarios from the PR test plan (still unchecked):
    • Drag across years on the production archive (1873→2023): bar window expands smoothly, release zooms.
    • Click year in year-mode: timeline rerenders 12 month bars with localized axis.
    • Add sender filter: density rescales.
    • ↩ reset-zoom restores; × clear restores unfiltered list.
    • Mobile/tablet (<1024 px) and ?view=calendar both hide the widget AND skip the density fetch.
  • 🟡 axe-core check — currently no axe-core/playwright run on /documents with the timeline mounted, light AND dark mode. Dark mode contrast must be tested independently — the comment on --timeline-bar-idle in dark mode claims 3.33:1; light mode acknowledges decorative-only contrast. Verify with a tool, both modes.
  • 🟡 Visual regression — at 1024px (lg breakpoint, where it first appears) and 1440px (typical desktop). Year-mode and zoomed-month-mode produce visibly different layouts; both worth pinning.

Smells

  • */* in generated api.ts despite produces=APPLICATION_JSON_VALUE on the controller. Runtime is correct (density_declaresApplicationJsonContentType test pins XML rejection), but SpringDoc isn't emitting application/json to the spec. If you regenerate after every backend change as policy, the type may drift. Worth a separate check — possibly a SpringDoc config tweak.
  • fetchDensity graceful-degrade swallows 401 — returns empty bucket with console.warn. If the entire page is auth-gated this is fine, but if a future PR makes any path to /documents reachable unauthenticated, the chart would silently render empty. A test for the 401 path returning EMPTY and emitting the warn would pin this.

Recommend before merge

Add at least one Playwright smoke test in this PR (one drag, one reset-zoom, one mobile-hide assertion) so the most critical journeys aren't entirely deferred to #480. Five minutes of axe-core/playwright output on /documents in light + dark mode would close the visual a11y gap too.

🤖 Generated with Claude Code

## 🧪 Sara Holt — QA Engineer **Verdict: ⚠️ Approved with concerns** Test pyramid coverage at every layer except the top. Unit, component, integration, and controller tests are thorough and well-named. The E2E gap is acknowledged via #480, which is the right call — but it must close before this feature is "done" by my Definition of Done. ### Pyramid coverage | Layer | Tests | Verdict | |---|---|---| | Static (TS, ESLint, svelte-check) | passes | 🟢 | | Unit — Java | `DocumentServiceTest` +4 (`getDensity` empty / groups / null-date filter / FTS short-circuit) | 🟢 | | Unit — TS | `timeline.spec.ts` 44 / `useTimelineDrag.svelte.test.ts` 13 | 🟢 | | Component — TS | `TimelineDensityFilter.svelte.spec.ts` 35 (visibility, axes, bars, selection, year fallback, zoom, touch targets, a11y, aria-live, drag-to-select-range, listener cleanup) | 🟢 | | Web slice — Java | `DocumentControllerTest` +6 (401, 200, content-type, cache header, sender+tag forwarding, status+q forwarding) | 🟢 | | Integration — Java | `DocumentDensityIntegrationTest` 7 against real Postgres (`@Import(PostgresContainerConfig.class)`, `@Transactional` rollback) | 🟢 | | Page integration — TS | `page.svelte.spec.ts` +5 (visibility, click navigation, zoom-reset URL clearing) | 🟢 | | **E2E — Playwright** | **none** | 🔴 | | Visual regression | none | 🟡 | | Load / k6 | none (acceptable for read endpoint at current scale) | — | ### What's done well - **`@WithMockUser`** on the protected endpoint tests, plus an explicit unauthenticated test pinning 401. Both auth states covered, not just the happy path. - **Real Postgres** for integration tests via Testcontainers (`PostgresContainerConfig`). No H2 substitution. - **Behavior over internals** — component tests use `getByTestId` / `aria-label` / class assertions that survive refactoring. No `$state` introspection. - **Edge cases covered** — empty density, null date bounds, year-aggregation threshold (252-bucket case), zoom into 3-month range, reverse-direction drag, pointercancel, click-suppression after pointerup, listener cleanup on unmount mid-drag. - **a11y assertions baked in** — `h-11 min-w-[44px]` for touch targets, `focus-visible:ring-*` for keyboard focus, `@media (hover: hover)` for touch-device hover-stick prevention, singular/plural ARIA forms — these are tested as code, not as a checklist. ### Gaps (must close before release) - **🔴 E2E coverage missing** — #480 tracks this. Required scenarios from the PR test plan (still unchecked): - Drag across years on the production archive (1873→2023): bar window expands smoothly, release zooms. - Click year in year-mode: timeline rerenders 12 month bars with localized axis. - Add sender filter: density rescales. - ↩ reset-zoom restores; × clear restores unfiltered list. - Mobile/tablet (<1024 px) and `?view=calendar` both hide the widget AND skip the density fetch. - **🟡 axe-core check** — currently no `axe-core/playwright` run on `/documents` with the timeline mounted, light AND dark mode. **Dark mode contrast must be tested independently** — the comment on `--timeline-bar-idle` in dark mode claims 3.33:1; light mode acknowledges decorative-only contrast. Verify with a tool, both modes. - **🟡 Visual regression** — at 1024px (lg breakpoint, where it first appears) and 1440px (typical desktop). Year-mode and zoomed-month-mode produce visibly different layouts; both worth pinning. ### Smells - **`*/*` in generated `api.ts`** despite `produces=APPLICATION_JSON_VALUE` on the controller. Runtime is correct (`density_declaresApplicationJsonContentType` test pins XML rejection), but SpringDoc isn't emitting `application/json` to the spec. If you regenerate after every backend change as policy, the type may drift. Worth a separate check — possibly a SpringDoc config tweak. - **`fetchDensity` graceful-degrade swallows 401** — returns empty bucket with `console.warn`. If the entire page is auth-gated this is fine, but if a future PR makes any path to `/documents` reachable unauthenticated, the chart would silently render empty. A test for the 401 path returning `EMPTY` and emitting the warn would pin this. ### Recommend before merge Add at least one Playwright smoke test in this PR (one drag, one reset-zoom, one mobile-hide assertion) so the most critical journeys aren't entirely deferred to #480. Five minutes of `axe-core/playwright` output on `/documents` in light + dark mode would close the visual a11y gap too. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Verdict: ⚠️ Approved with concerns

Visually polished, brand-token-correct, and unusually accessibility-aware for a chart component — touch targets, focus rings, reduced motion, hover gating, singular/plural ARIA, and an aria-live region for drag are all here in code, pinned by tests. But two concerns block this from being a "ship it" rather than "ship it with follow-up": keyboard inaccessibility for range selection, and dark-mode contrast for the selected/preview state.

Brand & visual

  • ✓ All colors via design tokens (--timeline-bar-idle, --timeline-bar-outside, --palette-mint, border-line, bg-surface). No raw hex in the components.
  • ✓ Card pattern correct: rounded-sm border border-line bg-surface shadow-sm with px-3 pt-3 pb-2.
  • ✓ Typography font-sans text-xs leading-none for axes — meets the 12 px floor (test pins not.toMatch(/text-\[10px\]/)). Good.
  • ✓ Reduced motion: @media (prefers-reduced-motion: reduce) strips the 100ms ease color transition. WCAG 2.3.3.

Accessibility — strengths

  • ✓ Touch targets: h-11 min-w-[44px] on reset-zoom, h-11 w-11 on clear. WCAG 2.5.8. Test-pinned.
  • ✓ Focus visibility: focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2 on every interactive element (bars, reset, clear). WCAG 2.4.7. Test-pinned and consistent.
  • ✓ Bars are real <button>s with aria-pressed, aria-label, and a localized template that distinguishes singular ("1 Dokument") from plural ("5 Dokumente"). Three locales. The aria-label test explicitly forbids the raw YYYY-MM machine string from leaking. Excellent.
  • role="group" + aria-label on the widget container. WCAG 4.1.2.
  • aria-live="polite" region during drag with localized "Range X to Y selected" — assistive tech actually narrates the in-progress range. Rare and well-done.
  • ✓ Hover gated under @media (hover: hover) — prevents touch-tap hover-stick. WCAG 2.5.7 spirit.
  • aria-hidden="true" on the X/Y axes (decorative — bar aria-label carries the count).

Concerns

🟡 Critical: Keyboard inaccessibility of range zoom (WCAG 2.1.1, A)

Tracked in #479. Repeating it here because severity matters: the drag-to-zoom gesture is the primary high-information interaction in this widget, and there is currently no keyboard equivalent. A keyboard or switch user can Tab to a bar and Enter to filter that single month, but cannot select a range.

This is a P1 in my book — Familienarchiv's senior persona (60+, tablet/laptop, often using keyboard or voice control rather than precise pointing) is the exact user who can't drag with a mouse. WCAG 2.1.1 is Level A — the floor, not a stretch goal. #479 must close in the next release, with explicit keyboard mappings spec'd before implementation:

Tab/Shift+Tab    move focus between bars
Enter / Space    filter to focused month/year
Shift+Arrow      extend selection toward a range start/end
Enter (in range) commit range filter + zoom
Escape           cancel in-progress range / clear focus selection

🟡 Major: Dark-mode contrast for the selected/preview state

--timeline-bar-idle in dark mode is #3a6e8c, claimed at 3.33:1 vs --c-surface (#011526). That's correct math and clears WCAG 1.4.11 for non-text UI. ✓

The selected and in-drag-preview state, however, falls back to var(--palette-mint, #a1dcd8). Against dark --c-surface (#011526), #a1dcd8 is ~10:1 — that's fine. But against the adjacent idle bar #3a6e8c the contrast between selected and idle is only ~2.4:1, which means a low-vision user can struggle to tell which bars are in the selection. The visible cue should be more than just the mint-vs-mid-tone-blue difference.

Fix recommendation (light + dark, both modes):

  • Add a 1-px solid mint border-top on the selected bar <span>, OR
  • Add a thin underline strip below the bar row spanning the selected range, OR
  • Use outline: 2px solid var(--palette-mint) on the selected bar's button (re-using the focus token).

Color alone is not the only cue today (aria-pressed="true" plus opacity 0.7 on in-drag-preview), but the visual signal for sighted-but-low-vision users is fragile. Tracked under #480 if you want to roll it into the axe pass; otherwise file as a small follow-up.

🟢 Minor

  • The light-mode idle fill (rgba(161, 220, 216, 0.35) over white ≈ #DEF3F1, ~1.13:1) is in the WCAG 1.4.11 carve-out for "decorative content". Comment in layout.css:3063 correctly acknowledges this. The selected state (mint at 1.52:1 over white) is also in that carve-out — same fix as above would solve it.
  • aria-pressed on the bars is correct usage (toggle state). Some screen readers handle this differently across the months when the user moves between selected/unselected — usually fine, but worth observing in the dark-mode + screen-reader pass for #480.

What I'd ship right now if I had pen tablet open

<button
    ...
    class="bar group ..."
    class:selected={isSelected(bucket.month)}
    class:in-drag-preview={isInDragPreview(i)}
>
    <span class="bar-fill block w-full rounded-t-[2px]" ... />
    <!-- new: a 2px mint cap for the selection state, drawn above the bar -->
    {#if isSelected(bucket.month) || isInDragPreview(i)}
        <span class="absolute top-0 left-0 right-0 h-[2px] bg-[var(--palette-mint)]" aria-hidden="true" />
    {/if}
</button>

That solves the dark-mode adjacency contrast and reinforces the selection visually for everyone.

🤖 Generated with Claude Code

## 🎨 Leonie Voss — UX Designer & Accessibility Strategist **Verdict: ⚠️ Approved with concerns** Visually polished, brand-token-correct, and unusually accessibility-aware for a chart component — touch targets, focus rings, reduced motion, hover gating, singular/plural ARIA, and an aria-live region for drag are all here in code, pinned by tests. **But two concerns block this from being a "ship it" rather than "ship it with follow-up"**: keyboard inaccessibility for range selection, and dark-mode contrast for the selected/preview state. ### Brand & visual - ✓ All colors via design tokens (`--timeline-bar-idle`, `--timeline-bar-outside`, `--palette-mint`, `border-line`, `bg-surface`). No raw hex in the components. - ✓ Card pattern correct: `rounded-sm border border-line bg-surface shadow-sm` with `px-3 pt-3 pb-2`. - ✓ Typography `font-sans text-xs leading-none` for axes — meets the 12 px floor (test pins `not.toMatch(/text-\[10px\]/)`). Good. - ✓ Reduced motion: `@media (prefers-reduced-motion: reduce)` strips the `100ms ease` color transition. WCAG 2.3.3. ### Accessibility — strengths - ✓ Touch targets: `h-11 min-w-[44px]` on reset-zoom, `h-11 w-11` on clear. WCAG 2.5.8. Test-pinned. - ✓ Focus visibility: `focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-2` on every interactive element (bars, reset, clear). WCAG 2.4.7. Test-pinned and consistent. - ✓ Bars are real `<button>`s with `aria-pressed`, `aria-label`, and a localized template that distinguishes singular ("1 Dokument") from plural ("5 Dokumente"). Three locales. The aria-label test explicitly forbids the raw `YYYY-MM` machine string from leaking. Excellent. - ✓ `role="group"` + `aria-label` on the widget container. WCAG 4.1.2. - ✓ `aria-live="polite"` region during drag with localized "Range X to Y selected" — assistive tech actually narrates the in-progress range. Rare and well-done. - ✓ Hover gated under `@media (hover: hover)` — prevents touch-tap hover-stick. WCAG 2.5.7 spirit. - ✓ `aria-hidden="true"` on the X/Y axes (decorative — bar `aria-label` carries the count). ### Concerns #### 🟡 Critical: Keyboard inaccessibility of range zoom (WCAG 2.1.1, A) Tracked in #479. Repeating it here because severity matters: the drag-to-zoom gesture is the primary high-information interaction in this widget, and there is currently **no keyboard equivalent**. A keyboard or switch user can `Tab` to a bar and `Enter` to filter that single month, but cannot select a range. This is a P1 in my book — Familienarchiv's senior persona (60+, tablet/laptop, often using keyboard or voice control rather than precise pointing) is the exact user who can't drag with a mouse. WCAG 2.1.1 is Level A — the floor, not a stretch goal. **#479 must close in the next release**, with explicit keyboard mappings spec'd before implementation: ``` Tab/Shift+Tab move focus between bars Enter / Space filter to focused month/year Shift+Arrow extend selection toward a range start/end Enter (in range) commit range filter + zoom Escape cancel in-progress range / clear focus selection ``` #### 🟡 Major: Dark-mode contrast for the selected/preview state `--timeline-bar-idle` in dark mode is `#3a6e8c`, claimed at 3.33:1 vs `--c-surface (#011526)`. That's correct math and clears WCAG 1.4.11 for non-text UI. ✓ The **selected and in-drag-preview** state, however, falls back to `var(--palette-mint, #a1dcd8)`. Against dark `--c-surface (#011526)`, `#a1dcd8` is ~10:1 — that's fine. **But against the *adjacent idle bar* `#3a6e8c`** the contrast between selected and idle is only ~2.4:1, which means a low-vision user can struggle to tell which bars are in the selection. The visible cue should be more than just the mint-vs-mid-tone-blue difference. **Fix recommendation** (light + dark, both modes): - Add a 1-px solid mint `border-top` on the selected bar `<span>`, OR - Add a thin underline strip below the bar row spanning the selected range, OR - Use `outline: 2px solid var(--palette-mint)` on the selected bar's button (re-using the focus token). Color alone is not the only cue today (`aria-pressed="true"` plus opacity 0.7 on in-drag-preview), but the *visual* signal for sighted-but-low-vision users is fragile. Tracked under #480 if you want to roll it into the axe pass; otherwise file as a small follow-up. #### 🟢 Minor - The light-mode idle fill (`rgba(161, 220, 216, 0.35)` over white ≈ #DEF3F1, ~1.13:1) is in the WCAG 1.4.11 carve-out for "decorative content". Comment in `layout.css:3063` correctly acknowledges this. The selected state (mint at 1.52:1 over white) is also in that carve-out — same fix as above would solve it. - `aria-pressed` on the bars is correct usage (toggle state). Some screen readers handle this differently across the months when the user moves between selected/unselected — usually fine, but worth observing in the dark-mode + screen-reader pass for #480. ### What I'd ship right now if I had pen tablet open ```svelte <button ... class="bar group ..." class:selected={isSelected(bucket.month)} class:in-drag-preview={isInDragPreview(i)} > <span class="bar-fill block w-full rounded-t-[2px]" ... /> <!-- new: a 2px mint cap for the selection state, drawn above the bar --> {#if isSelected(bucket.month) || isInDragPreview(i)} <span class="absolute top-0 left-0 right-0 h-[2px] bg-[var(--palette-mint)]" aria-hidden="true" /> {/if} </button> ``` That solves the dark-mode adjacency contrast and reinforces the selection visually for everyone. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel merged commit 9fd1f3cde2 into main 2026-05-08 12:27:17 +02:00
marcel deleted branch feat/issue-385-timeline-density-filter 2026-05-08 12:27:18 +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#478