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

Closed
opened 2026-05-03 09:02:40 +02:00 by marcel · 13 comments
Owner

Context

The documents list currently has no visual date navigation. Users need to manually enter dates or scroll to explore a period. A horizontal timeline widget lets users see when documents cluster across the archive and select a range to filter the list.

User Story

As a family member browsing the archive, I want to select a date range on a visual timeline that shows document density, so that I can quickly zoom into a period of interest without typing dates.

Acceptance Criteria

Given the /documents page is open in list view
Then a horizontal timeline widget is visible showing the full date span of the archive
And bar height per month reflects the relative number of documents in that month

Given the user drags or clicks to select a start and end point on the timeline
Then the document list is filtered to that date range
And all other active filters (person, tag) remain applied (AND semantics)

Given a date range is selected
When the user clears the selection
Then the date filter is removed and the full list is restored

Given no documents exist in a month
Then that month shows a zero-height bar (no bar rendered)

Given the user switches to calendar view
Then the timeline widget is hidden (calendar manages its own month navigation)

Open Questions

  • OQ-1: Does the timeline axis auto-derive its range from the earliest and latest documentDate in the database, or is it fixed (e.g. 1880–present)?
  • OQ-2: Minimum selection granularity — full months only, or can the user select partial months / individual weeks?
## Context The documents list currently has no visual date navigation. Users need to manually enter dates or scroll to explore a period. A horizontal timeline widget lets users see *when* documents cluster across the archive and select a range to filter the list. ## User Story As a family member browsing the archive, I want to select a date range on a visual timeline that shows document density, so that I can quickly zoom into a period of interest without typing dates. ## Acceptance Criteria ```gherkin Given the /documents page is open in list view Then a horizontal timeline widget is visible showing the full date span of the archive And bar height per month reflects the relative number of documents in that month Given the user drags or clicks to select a start and end point on the timeline Then the document list is filtered to that date range And all other active filters (person, tag) remain applied (AND semantics) Given a date range is selected When the user clears the selection Then the date filter is removed and the full list is restored Given no documents exist in a month Then that month shows a zero-height bar (no bar rendered) Given the user switches to calendar view Then the timeline widget is hidden (calendar manages its own month navigation) ``` ## Open Questions - **OQ-1:** Does the timeline axis auto-derive its range from the earliest and latest `documentDate` in the database, or is it fixed (e.g. 1880–present)? - **OQ-2:** Minimum selection granularity — full months only, or can the user select partial months / individual weeks?
marcel added this to the Reader Experience v1 milestone 2026-05-03 09:02:40 +02:00
marcel added the P2-mediumfeatureui labels 2026-05-03 09:03:03 +02:00
Author
Owner

Q-1: must be auto-derive
Q-2 Only full months

Q-1: must be auto-derive Q-2 Only full months
Author
Owner

🏗️ Markus Keller — Application Architect

Observations

New backend endpoint needed — fits cleanly in the document domain. The spec proposes GET /api/documents/density returning [{month:"1915-08", count:24}, ...]. This is read-only aggregation over the documents table. It belongs in DocumentController / DocumentService, not in a new controller, because the data comes entirely from the document domain. StatsController / StatsService handles global aggregate dashboard stats (/api/stats) — density-by-month is a different concern tied to the document list context, so keep it in the document package.

No new infrastructure required. The query is a simple GROUP BY date_trunc('month', document_date) on PostgreSQL — a pure SQL aggregation with no joins needed beyond the documents table. No new table, no Flyway migration, no Redis, no message broker.

The ?dateFrom / ?dateTo filter already exists. DocumentController.search() already accepts LocalDate from and LocalDate to. The frontend already wires these via URL params. The density endpoint feeds the widget with bucket counts; the existing search endpoint does the actual filtering. No overlap, no duplication.

Spec proposes month-string parameters (YYYY-MM) for the density endpoint. The current search endpoint uses full LocalDate (YYYY-MM-DD). For consistency, I recommend reusing LocalDate at the HTTP boundary and let the backend compute the first/last day of the month internally. This avoids a second date format in the API surface and keeps TypeScript generation clean. Alternatively, a dedicated YearMonth parameter type with a custom deserializer works — but only if you keep the two formats from leaking into the same DTO.

Auto-derived range (OQ-1 answered correctly in spec). The density endpoint should also return minDate and maxDate from the live data so the frontend can set the x-axis without a separate call. Shape: { buckets: [{month, count}], minDate, maxDate }. One round trip instead of two.

Caching opportunity. The density distribution across the whole archive changes only when documents are created, updated, or deleted — not on every page load. A simple @Cacheable("document-density") with cache eviction on write operations would eliminate the aggregation query on repeated loads. This is optional for MVP but worth noting as the archive grows.

Recommendations

  1. Add GET /api/documents/density to DocumentController. Return a new record DocumentDensityResult(List<MonthBucket> buckets, LocalDate minDate, LocalDate maxDate) where MonthBucket is a record (String month, int count) with month in YYYY-MM format.
  2. Implement via a JPQL or native SQL query in DocumentRepository using date_trunc and GROUP BY. Native query is fine here — the aggregation logic is simpler and more performant as native SQL.
  3. Reuse LocalDate from / LocalDate to as optional query params on the density endpoint (consistent with the existing search endpoint). The backend maps them to month boundaries internally.
  4. No @RequirePermission needed beyond authentication — the search endpoint is also unauthenticated-accessible (same pattern). Verify the security config covers both consistently.
  5. After adding the endpoint: run npm run generate:api in frontend/ — the new response type will appear as DocumentDensityResult in TypeScript.
  6. Update docs/architecture/c4/l3-backend-*.puml if the density endpoint represents a meaningfully new capability (analytics vs. CRUD) — check whether it warrants its own component or sits alongside the existing search component.

Open Decisions

  • Density endpoint URL: /api/documents/density (in document domain) vs. /api/stats/document-density (in dashboard domain). The data is document-domain data; the use case is search/navigation, not a dashboard KPI. I recommend the document domain, but the dashboard framing is defensible if you want all analytics in one place.
## 🏗️ Markus Keller — Application Architect ### Observations **New backend endpoint needed — fits cleanly in the document domain.** The spec proposes `GET /api/documents/density` returning `[{month:"1915-08", count:24}, ...]`. This is read-only aggregation over the `documents` table. It belongs in `DocumentController` / `DocumentService`, not in a new controller, because the data comes entirely from the document domain. `StatsController` / `StatsService` handles global aggregate dashboard stats (`/api/stats`) — density-by-month is a different concern tied to the document list context, so keep it in the document package. **No new infrastructure required.** The query is a simple `GROUP BY date_trunc('month', document_date)` on PostgreSQL — a pure SQL aggregation with no joins needed beyond the `documents` table. No new table, no Flyway migration, no Redis, no message broker. **The `?dateFrom` / `?dateTo` filter already exists.** `DocumentController.search()` already accepts `LocalDate from` and `LocalDate to`. The frontend already wires these via URL params. The density endpoint feeds the widget with bucket counts; the existing search endpoint does the actual filtering. No overlap, no duplication. **Spec proposes month-string parameters (`YYYY-MM`) for the density endpoint.** The current search endpoint uses full `LocalDate` (`YYYY-MM-DD`). For consistency, I recommend reusing `LocalDate` at the HTTP boundary and let the backend compute the first/last day of the month internally. This avoids a second date format in the API surface and keeps TypeScript generation clean. Alternatively, a dedicated `YearMonth` parameter type with a custom deserializer works — but only if you keep the two formats from leaking into the same DTO. **Auto-derived range (OQ-1 answered correctly in spec).** The density endpoint should also return `minDate` and `maxDate` from the live data so the frontend can set the x-axis without a separate call. Shape: `{ buckets: [{month, count}], minDate, maxDate }`. One round trip instead of two. **Caching opportunity.** The density distribution across the whole archive changes only when documents are created, updated, or deleted — not on every page load. A simple `@Cacheable("document-density")` with cache eviction on write operations would eliminate the aggregation query on repeated loads. This is optional for MVP but worth noting as the archive grows. ### Recommendations 1. Add `GET /api/documents/density` to `DocumentController`. Return a new record `DocumentDensityResult(List<MonthBucket> buckets, LocalDate minDate, LocalDate maxDate)` where `MonthBucket` is a record `(String month, int count)` with month in `YYYY-MM` format. 2. Implement via a JPQL or native SQL query in `DocumentRepository` using `date_trunc` and `GROUP BY`. Native query is fine here — the aggregation logic is simpler and more performant as native SQL. 3. Reuse `LocalDate from` / `LocalDate to` as optional query params on the density endpoint (consistent with the existing search endpoint). The backend maps them to month boundaries internally. 4. No `@RequirePermission` needed beyond authentication — the search endpoint is also unauthenticated-accessible (same pattern). Verify the security config covers both consistently. 5. After adding the endpoint: run `npm run generate:api` in `frontend/` — the new response type will appear as `DocumentDensityResult` in TypeScript. 6. Update `docs/architecture/c4/l3-backend-*.puml` if the density endpoint represents a meaningfully new capability (analytics vs. CRUD) — check whether it warrants its own component or sits alongside the existing search component. ### Open Decisions - **Density endpoint URL**: `/api/documents/density` (in document domain) vs. `/api/stats/document-density` (in dashboard domain). The data is document-domain data; the use case is search/navigation, not a dashboard KPI. I recommend the document domain, but the dashboard framing is defensible if you want all analytics in one place.
Author
Owner

👨‍💻 Felix Brandt — Fullstack Developer

Observations

Component split is well-defined in the spec. TimelineDensityFilter.svelte is the right name and boundary. Props: density: MonthBucket[], bind:from, bind:to, onchange. This is a single visual region with clear inputs/outputs — one component, no split needed unless it exceeds ~60 lines of template.

Drag interaction is the complex part. Click-to-select a single month is straightforward. Drag to select a range requires tracking mousedown, mousemove, and mouseup across the document (not just the widget) — the mouse leaves the bar area during a fast drag. Use a $state boolean isDragging + $effect to attach/detach document-level listeners during drag. Clean up in the effect's cleanup function. Do not use onMount + manual cleanup — that's the pre-runes pattern.

from and to as string (YYYY-MM) or as structured { year, month }? The existing +page.server.ts already handles from and to as ISO date strings in the URL (from=1914-01-01). The timeline operates on full months, so the natural internal representation is YearMonth = { year: number; month: number }. Convert to YYYY-MM-01 and YYYY-MM-28/29/30/31 before writing to the URL. A $derived for this conversion is clean.

The spec's MonthBucket[] needs a TypeScript type. After running npm run generate:api, the backend's MonthBucket record will appear as a generated type. Use it directly — don't define a parallel hand-written interface.

Loading state matters. The density data loads server-side in +page.server.ts alongside the document search results. If the density endpoint fails, the page should still render the document list — return density: [] as a graceful fallback, not throw error(...). An empty density array renders the widget with no bars, which is better than a broken page.

The buildSearchParams function in +page.svelte needs to carry from and to from the timeline widget. Looking at the existing code, from and to are already local $state variables fed into buildSearchParams. The timeline widget will bind:from and bind:to to those same state variables and call triggerSearch() on change. No architectural change needed — just wire the existing filter state.

Mobile collapse (spec section 5). The spec says sm:block hidden for the chart and sm:hidden for a text-only badge. This is correct Tailwind. Implement both at the same time — skeleton out the mobile badge even for MVP so the behaviour is consistent from day one.

Recommendations

  1. Load density data in +page.server.ts with a parallel fetch alongside the document search: const [searchResult, densityResult] = await Promise.all([..., ...]). This avoids waterfall loading.
  2. Represent drag state as: let dragStart: number | null = $state(null) (bar index) and let dragEnd: number | null = $state(null). Derive fromMonth and toMonth from $derived. Write to from/to URL params only on mouseup (commit) — not on every mousemove (that would trigger a server reload on each pixel).
  3. Keep intermediate drag state local and only propagate to from/to on commit. The visual selection (bar colors) updates live during drag; the document list re-fetches only on commit.
  4. Use {#each density as bucket, i (bucket.month)} — keyed by month string, which is a stable ID.
  5. Extract the bar height computation to a $derived: const maxCount = $derived(Math.max(...density.map(b => b.count), 1)) and compute each bar's height as a percentage inline. Avoid $effect for this.
  6. The handle drag targets in the spec are 12×12px visually but need a 44×44px invisible tap area — wrap each handle in a transparent <button> with class="absolute w-11 h-11 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize" and the visual dot as a child. This satisfies the 44px touch target requirement without changing the visual design.
## 👨‍💻 Felix Brandt — Fullstack Developer ### Observations **Component split is well-defined in the spec.** `TimelineDensityFilter.svelte` is the right name and boundary. Props: `density: MonthBucket[]`, `bind:from`, `bind:to`, `onchange`. This is a single visual region with clear inputs/outputs — one component, no split needed unless it exceeds ~60 lines of template. **Drag interaction is the complex part.** Click-to-select a single month is straightforward. Drag to select a range requires tracking `mousedown`, `mousemove`, and `mouseup` across the document (not just the widget) — the mouse leaves the bar area during a fast drag. Use a `$state` boolean `isDragging` + `$effect` to attach/detach document-level listeners during drag. Clean up in the effect's cleanup function. Do not use `onMount` + manual cleanup — that's the pre-runes pattern. **`from` and `to` as `string` (YYYY-MM) or as structured `{ year, month }`?** The existing `+page.server.ts` already handles `from` and `to` as ISO date strings in the URL (`from=1914-01-01`). The timeline operates on full months, so the natural internal representation is `YearMonth = { year: number; month: number }`. Convert to `YYYY-MM-01` and `YYYY-MM-28/29/30/31` before writing to the URL. A `$derived` for this conversion is clean. **The spec's `MonthBucket[]` needs a TypeScript type.** After running `npm run generate:api`, the backend's `MonthBucket` record will appear as a generated type. Use it directly — don't define a parallel hand-written interface. **Loading state matters.** The density data loads server-side in `+page.server.ts` alongside the document search results. If the density endpoint fails, the page should still render the document list — return `density: []` as a graceful fallback, not `throw error(...)`. An empty `density` array renders the widget with no bars, which is better than a broken page. **The `buildSearchParams` function in `+page.svelte` needs to carry `from` and `to` from the timeline widget.** Looking at the existing code, `from` and `to` are already local `$state` variables fed into `buildSearchParams`. The timeline widget will `bind:from` and `bind:to` to those same state variables and call `triggerSearch()` on change. No architectural change needed — just wire the existing filter state. **Mobile collapse (spec section 5).** The spec says `sm:block hidden` for the chart and `sm:hidden` for a text-only badge. This is correct Tailwind. Implement both at the same time — skeleton out the mobile badge even for MVP so the behaviour is consistent from day one. ### Recommendations 1. Load density data in `+page.server.ts` with a parallel fetch alongside the document search: `const [searchResult, densityResult] = await Promise.all([..., ...])`. This avoids waterfall loading. 2. Represent drag state as: `let dragStart: number | null = $state(null)` (bar index) and `let dragEnd: number | null = $state(null)`. Derive `fromMonth` and `toMonth` from `$derived`. Write to `from`/`to` URL params only on `mouseup` (commit) — not on every `mousemove` (that would trigger a server reload on each pixel). 3. Keep intermediate drag state local and only propagate to `from`/`to` on commit. The visual selection (bar colors) updates live during drag; the document list re-fetches only on commit. 4. Use `{#each density as bucket, i (bucket.month)}` — keyed by `month` string, which is a stable ID. 5. Extract the bar height computation to a `$derived`: `const maxCount = $derived(Math.max(...density.map(b => b.count), 1))` and compute each bar's height as a percentage inline. Avoid `$effect` for this. 6. The handle drag targets in the spec are 12×12px visually but need a 44×44px invisible tap area — wrap each handle in a transparent `<button>` with `class="absolute w-11 h-11 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize"` and the visual dot as a child. This satisfies the 44px touch target requirement without changing the visual design.
Author
Owner

🔒 Nora "NullX" Steiner — Security Engineer

Observations

No authentication gap on the density endpoint. The existing GET /api/documents/search is publicly accessible (no @RequirePermission annotation in the controller). The density endpoint should follow the same access rule — if search is open, density must be open too (it reveals the same aggregate information). Inconsistency here would be a gap in either direction: either both need @RequirePermission(Permission.READ_ALL) or neither does. Check SecurityConfig to confirm the current default behavior — the controller has no annotation but Spring Security's anyRequest().authenticated() may still require a session.

The density response is aggregate-only — no PII exposed. {month: "1915-08", count: 24} does not expose document content, sender names, or receiver names. From a data minimization perspective (GDPR Article 5), this is clean.

Native SQL query for date_trunc — parameterization is required. If the density endpoint accepts optional from / to date bounds, those must be passed as named parameters in the native query, not concatenated into the query string. The existing DocumentSpecifications.isBetween() uses the JPA Criteria API correctly for the search endpoint. Extend the same discipline to the density query:

// Safe
@Query(value = "SELECT TO_CHAR(DATE_TRUNC('month', document_date), 'YYYY-MM') AS month, COUNT(*) AS count " +
               "FROM documents WHERE (:from IS NULL OR document_date >= :from) " +
               "AND (:to IS NULL OR document_date <= :to) " +
               "GROUP BY 1 ORDER BY 1", nativeQuery = true)
List<Object[]> findDensityByMonth(@Param("from") LocalDate from, @Param("to") LocalDate to);

No SSRF risk. This feature does not involve any user-supplied URLs, file uploads, or outbound HTTP calls. SSRF and path traversal are not applicable here.

Log injection risk is negligible. The density endpoint takes only date parameters (validated as LocalDate by Spring). There is no free-form string input to log. No CWE-117 exposure.

Dark mode badge in SearchFilterBar (spec Section 3, active state). The × clear button is rendered inline in a <span> with a click handler. Ensure this is a <button> (not a <span> or <div>) — screen readers need a proper interactive element to announce, and keyboard users need to reach it with Tab. A <span onclick> is an accessibility and minor security concern (users cannot tell it's interactive without visual inspection).

Recommendations

  1. Confirm the authentication rule for the density endpoint by checking SecurityConfig before implementing. Match the rule to the existing search endpoint — do not introduce a new asymmetry.
  2. Implement the density query with named parameters only — no string concatenation. Use Spring Data's @Query(nativeQuery = true) with @Param.
  3. Use a <button> (not <span>) for the clear-selection × control in SearchFilterBar. Apply aria-label={m.timeline_clear_selection()} so the action is announced to screen readers.
  4. No new ErrorCode needed for the density endpoint — a missing date range is not a domain error, just an empty result. Return an empty buckets: [] with minDate: null, maxDate: null when the table has no rows.
## 🔒 Nora "NullX" Steiner — Security Engineer ### Observations **No authentication gap on the density endpoint.** The existing `GET /api/documents/search` is publicly accessible (no `@RequirePermission` annotation in the controller). The density endpoint should follow the same access rule — if search is open, density must be open too (it reveals the same aggregate information). Inconsistency here would be a gap in either direction: either both need `@RequirePermission(Permission.READ_ALL)` or neither does. Check `SecurityConfig` to confirm the current default behavior — the controller has no annotation but Spring Security's `anyRequest().authenticated()` may still require a session. **The density response is aggregate-only — no PII exposed.** `{month: "1915-08", count: 24}` does not expose document content, sender names, or receiver names. From a data minimization perspective (GDPR Article 5), this is clean. **Native SQL query for `date_trunc` — parameterization is required.** If the density endpoint accepts optional `from` / `to` date bounds, those must be passed as named parameters in the native query, not concatenated into the query string. The existing `DocumentSpecifications.isBetween()` uses the JPA Criteria API correctly for the search endpoint. Extend the same discipline to the density query: ```java // Safe @Query(value = "SELECT TO_CHAR(DATE_TRUNC('month', document_date), 'YYYY-MM') AS month, COUNT(*) AS count " + "FROM documents WHERE (:from IS NULL OR document_date >= :from) " + "AND (:to IS NULL OR document_date <= :to) " + "GROUP BY 1 ORDER BY 1", nativeQuery = true) List<Object[]> findDensityByMonth(@Param("from") LocalDate from, @Param("to") LocalDate to); ``` **No SSRF risk.** This feature does not involve any user-supplied URLs, file uploads, or outbound HTTP calls. SSRF and path traversal are not applicable here. **Log injection risk is negligible.** The density endpoint takes only date parameters (validated as `LocalDate` by Spring). There is no free-form string input to log. No CWE-117 exposure. **Dark mode badge in SearchFilterBar (spec Section 3, active state).** The `×` clear button is rendered inline in a `<span>` with a click handler. Ensure this is a `<button>` (not a `<span>` or `<div>`) — screen readers need a proper interactive element to announce, and keyboard users need to reach it with Tab. A `<span onclick>` is an accessibility and minor security concern (users cannot tell it's interactive without visual inspection). ### Recommendations 1. Confirm the authentication rule for the density endpoint by checking `SecurityConfig` before implementing. Match the rule to the existing search endpoint — do not introduce a new asymmetry. 2. Implement the density query with named parameters only — no string concatenation. Use Spring Data's `@Query(nativeQuery = true)` with `@Param`. 3. Use a `<button>` (not `<span>`) for the clear-selection `×` control in SearchFilterBar. Apply `aria-label={m.timeline_clear_selection()}` so the action is announced to screen readers. 4. No new `ErrorCode` needed for the density endpoint — a missing date range is not a domain error, just an empty result. Return an empty `buckets: []` with `minDate: null, maxDate: null` when the table has no rows.
Author
Owner

🧪 Sara Holt — QA Engineer

Observations

Three distinct test layers needed. This feature introduces (a) a new backend aggregation query, (b) a new Svelte component with drag interaction, and (c) integration of the component with the existing +page.svelte filter state. Each layer has its own testing concern.

Backend — the density query needs a real PostgreSQL test. The date_trunc('month', document_date) function is PostgreSQL-specific and will not behave identically in H2. This codebase already uses Testcontainers for integration tests (DocumentRepositoryTest, DocumentSearchPagedIntegrationTest). The density query test must follow the same pattern — @DataJpaTest or @SpringBootTest with PostgreSQLContainer. Key test cases: zero documents, single month with documents, multi-month spread, from/to bounds that exclude some months, and months with no documents (must not appear in results, or appear with count 0 — the spec says show min-height bar; verify the backend vs. frontend contract here).

Frontend component — drag logic is stateful and hard to cover with happy-path-only tests. The TimelineDensityFilter.svelte component needs vitest-browser tests covering: (1) idle render shows all bars, (2) click on one bar selects that month, (3) drag from bar A to bar B sets from to A and to to B, (4) clear button removes selection, (5) empty density array renders widget without crashing. The drag simulation is the tricky one — vitest-browser-svelte runs in real Chromium so dispatchEvent(new MouseEvent('mousedown')) / mousemove / mouseup sequences work. Reference the existing pattern in the edit-page delete spec which was recently fixed to use dispatchEvent.

Page-level integration. The +page.svelte load function needs a test verifying that when the density endpoint fails, density falls back to [] and the document list still renders (graceful degradation). Test the +page.server.ts load function directly by mocking fetch — this is the existing pattern in the codebase.

Missing AC coverage. The acceptance criteria cover: (1) widget visible on list view, (2) bar height reflects density, (3) drag/click filters list, (4) AND semantics with other filters, (5) clear restores full list, (6) zero-count month shows no bar, (7) calendar view hides widget. I count seven scenarios — each deserves at least one test. AC #7 (calendar view hides widget) is currently unspecified for how the view toggle works — this is a testability gap that needs clarification before implementation.

Keyboard navigation (spec behaviour table row "Keyboard-Zugang"). The spec defines Tab → arrow keys → Enter/Space for selection. This is a complex keyboard interaction that needs an explicit E2E test — it cannot be adequately covered at the component layer because it depends on focus order within the page.

Recommendations

  1. Write backend test DocumentDensityQueryTest using Testcontainers PostgreSQL. Test cases: should_return_empty_list_when_no_documents_exist, should_group_documents_by_month, should_respect_date_bounds. Mark the test class with the existing @Import(PostgresContainerConfig.class) pattern.
  2. Write vitest-browser component tests in frontend/src/lib/document/TimelineDensityFilter.test.ts. Use factory: const makeDensity = (overrides = {}) => ({ month: '1915-08', count: 10, ...overrides }).
  3. Test error paths in +page.server.ts: when the density API returns non-ok, density is [] and no exception propagates.
  4. Add one Playwright E2E test for the keyboard selection flow — this is the one interaction that cannot be adequately covered at the unit/component layer.
  5. Before implementing, clarify how showCalendarView state is represented (URL param? local $state?) so the "widget hidden in calendar view" AC can be tested at the right layer.

Open Decisions

  • Zero-count months in the density response: should the backend include {month: "1922-03", count: 0} for months within the archive range that have no documents, or omit them and let the frontend infer the zero? The spec says "Mindesthöhe von 2px" for zero-count months, which implies the frontend must render them — but the backend needs to supply them. This is a backend/frontend contract that needs explicit alignment before writing tests.
## 🧪 Sara Holt — QA Engineer ### Observations **Three distinct test layers needed.** This feature introduces (a) a new backend aggregation query, (b) a new Svelte component with drag interaction, and (c) integration of the component with the existing `+page.svelte` filter state. Each layer has its own testing concern. **Backend — the density query needs a real PostgreSQL test.** The `date_trunc('month', document_date)` function is PostgreSQL-specific and will not behave identically in H2. This codebase already uses Testcontainers for integration tests (`DocumentRepositoryTest`, `DocumentSearchPagedIntegrationTest`). The density query test must follow the same pattern — `@DataJpaTest` or `@SpringBootTest` with `PostgreSQLContainer`. Key test cases: zero documents, single month with documents, multi-month spread, `from`/`to` bounds that exclude some months, and months with no documents (must not appear in results, or appear with count 0 — the spec says show min-height bar; verify the backend vs. frontend contract here). **Frontend component — drag logic is stateful and hard to cover with happy-path-only tests.** The `TimelineDensityFilter.svelte` component needs vitest-browser tests covering: (1) idle render shows all bars, (2) click on one bar selects that month, (3) drag from bar A to bar B sets `from` to A and `to` to B, (4) clear button removes selection, (5) empty `density` array renders widget without crashing. The drag simulation is the tricky one — `vitest-browser-svelte` runs in real Chromium so `dispatchEvent(new MouseEvent('mousedown'))` / `mousemove` / `mouseup` sequences work. Reference the existing pattern in the edit-page delete spec which was recently fixed to use `dispatchEvent`. **Page-level integration.** The `+page.svelte` load function needs a test verifying that when the density endpoint fails, `density` falls back to `[]` and the document list still renders (graceful degradation). Test the `+page.server.ts` load function directly by mocking `fetch` — this is the existing pattern in the codebase. **Missing AC coverage.** The acceptance criteria cover: (1) widget visible on list view, (2) bar height reflects density, (3) drag/click filters list, (4) AND semantics with other filters, (5) clear restores full list, (6) zero-count month shows no bar, (7) calendar view hides widget. I count seven scenarios — each deserves at least one test. AC #7 (calendar view hides widget) is currently unspecified for how the view toggle works — this is a testability gap that needs clarification before implementation. **Keyboard navigation (spec behaviour table row "Keyboard-Zugang").** The spec defines Tab → arrow keys → Enter/Space for selection. This is a complex keyboard interaction that needs an explicit E2E test — it cannot be adequately covered at the component layer because it depends on focus order within the page. ### Recommendations 1. Write backend test `DocumentDensityQueryTest` using Testcontainers PostgreSQL. Test cases: `should_return_empty_list_when_no_documents_exist`, `should_group_documents_by_month`, `should_respect_date_bounds`. Mark the test class with the existing `@Import(PostgresContainerConfig.class)` pattern. 2. Write vitest-browser component tests in `frontend/src/lib/document/TimelineDensityFilter.test.ts`. Use factory: `const makeDensity = (overrides = {}) => ({ month: '1915-08', count: 10, ...overrides })`. 3. Test error paths in `+page.server.ts`: when the density API returns non-ok, `density` is `[]` and no exception propagates. 4. Add one Playwright E2E test for the keyboard selection flow — this is the one interaction that cannot be adequately covered at the unit/component layer. 5. Before implementing, clarify how `showCalendarView` state is represented (URL param? local `$state`?) so the "widget hidden in calendar view" AC can be tested at the right layer. ### Open Decisions - **Zero-count months in the density response**: should the backend include `{month: "1922-03", count: 0}` for months within the archive range that have no documents, or omit them and let the frontend infer the zero? The spec says "Mindesthöhe von 2px" for zero-count months, which implies the frontend must render them — but the backend needs to supply them. This is a backend/frontend contract that needs explicit alignment before writing tests.
Author
Owner

🎨 Leonie Voss — UI/UX Lead

Observations

Spec is production-ready. The timeline-density-filter-spec.html covers all seven anatomy zones, four states (idle, hover, active selection, clear), dark mode with inverted colour roles, mobile collapse, and an implementation reference table with exact Tailwind classes. This is sufficient for implementation without further design work.

One contrast concern in dark mode. In the dark idle state, the spec uses #0E2535 for bar-idle on a #0A1218 background. The contrast between these two values is approximately 1.3:1 — below WCAG's informational graphic threshold of 3:1. The bars in idle dark mode will be nearly invisible on the dark background. The light mode counterpart (#D4EDE9 on #FFFFFF) is approximately 1.4:1 — the same issue exists there but is less severe because the light bars are mint-tinted rather than near-black. This is intentional per the spec ("helles Mint, nicht brand-mint — zu dominant"), but it may be too subtle in practice. Recommended fix: raise bar-idle opacity or use a slightly lighter value (#1A3A52 instead of #0E2535) to reach 2:1 minimum for non-text graphics. WCAG 2.1 SC 1.4.11 requires 3:1 for UI components — this applies to the bars when no selection is active.

The drag handle touch target is correctly specified. The spec says "mind. 44px durch p-4 auf einem unsichtbaren Wrapper" — this is the right approach. Confirm the invisible wrapper has touch-action: none to prevent scroll interference during drag on mobile.

Tooltip clipping at widget edges. The spec uses left: 50%; transform: translateX(-50%) for the tooltip, which will overflow left on the first bar and right on the last bar. Add max(8px, calc(50% - {barWidth/2}px)) clamping or use CSS clamp() with a min/max bound. For the first 2–3 bars and last 2–3 bars, pin the tooltip to the left or right edge of the widget respectively.

Mobile behaviour (spec: widget collapses to badge). The spec says sm:block hidden for the chart and sm:hidden for the mobile badge. "Tap öffnet ein Modal mit dem vollen Widget." — the modal is mentioned but not designed. This is a gap for mobile-first users. For MVP, the text-badge-only approach (no modal, tapping the badge opens the month filter text inputs) is simpler and sufficient. Flag the modal as a future enhancement.

Year labels every 10 years is correct for the 1900–1950 archive span. If the archive ever extends beyond 100 years, the label density logic should be dynamic (label every N years based on total span ÷ available width). Not a concern now but worth a // TODO comment in the component.

The bar-outside dimming (#E8E6E0 in light mode) creates a subtle but meaningful state difference. This is good — users can still see the distribution shape while the selection is active. Ensure the transition between bar-idle and bar-outside is instant (no transition) when the selection is first set; only the bar-hover state should animate.

Recommendations

  1. Raise the dark-mode bar-idle color from #0E2535 to approximately #1C3F5C to reach a 2:1 contrast ratio against the #0A1218 background. This keeps the bars subtle without making them invisible.
  2. Implement tooltip edge-clamping using CSS clamp() on the left value: clamp(50%, calc(var(--bar-offset) + 50%), calc(100% - var(--tooltip-width)/2)). Simpler approach: use translateX(-50%) everywhere but clip the tooltip's parent container with overflow: visible and constrain the tooltip with max-w-[90%] left-0 right-0.
  3. Add touch-action: none to the drag-handle wrapper element to prevent the browser scroll gesture from competing with the horizontal drag on mobile.
  4. For MVP, implement the mobile collapse as a static text line "Zeitraum: [Jan 1914 – Dez 1920] ×" that taps to clear. Defer the modal to a follow-up issue. Add a comment in the component marking the modal as a // TODO: #386 (create that issue).
  5. Use transition-colors duration-100 only on bar-idle → bar-hover. The bar-selected and bar-outside state changes should be instant (no transition) to avoid the selection feeling laggy during drag.
## 🎨 Leonie Voss — UI/UX Lead ### Observations **Spec is production-ready.** The `timeline-density-filter-spec.html` covers all seven anatomy zones, four states (idle, hover, active selection, clear), dark mode with inverted colour roles, mobile collapse, and an implementation reference table with exact Tailwind classes. This is sufficient for implementation without further design work. **One contrast concern in dark mode.** In the dark idle state, the spec uses `#0E2535` for bar-idle on a `#0A1218` background. The contrast between these two values is approximately 1.3:1 — below WCAG's informational graphic threshold of 3:1. The bars in idle dark mode will be nearly invisible on the dark background. The light mode counterpart (`#D4EDE9` on `#FFFFFF`) is approximately 1.4:1 — the same issue exists there but is less severe because the light bars are mint-tinted rather than near-black. **This is intentional per the spec ("helles Mint, nicht brand-mint — zu dominant")**, but it may be too subtle in practice. Recommended fix: raise bar-idle opacity or use a slightly lighter value (`#1A3A52` instead of `#0E2535`) to reach 2:1 minimum for non-text graphics. WCAG 2.1 SC 1.4.11 requires 3:1 for UI components — this applies to the bars when no selection is active. **The drag handle touch target is correctly specified.** The spec says "mind. 44px durch `p-4` auf einem unsichtbaren Wrapper" — this is the right approach. Confirm the invisible wrapper has `touch-action: none` to prevent scroll interference during drag on mobile. **Tooltip clipping at widget edges.** The spec uses `left: 50%; transform: translateX(-50%)` for the tooltip, which will overflow left on the first bar and right on the last bar. Add `max(8px, calc(50% - {barWidth/2}px))` clamping or use CSS `clamp()` with a min/max bound. For the first 2–3 bars and last 2–3 bars, pin the tooltip to the left or right edge of the widget respectively. **Mobile behaviour (spec: widget collapses to badge).** The spec says `sm:block hidden` for the chart and `sm:hidden` for the mobile badge. "Tap öffnet ein Modal mit dem vollen Widget." — the modal is mentioned but not designed. This is a gap for mobile-first users. For MVP, the text-badge-only approach (no modal, tapping the badge opens the month filter text inputs) is simpler and sufficient. Flag the modal as a future enhancement. **Year labels every 10 years is correct for the 1900–1950 archive span.** If the archive ever extends beyond 100 years, the label density logic should be dynamic (label every N years based on total span ÷ available width). Not a concern now but worth a `// TODO` comment in the component. **The `bar-outside` dimming (`#E8E6E0` in light mode) creates a subtle but meaningful state difference.** This is good — users can still see the distribution shape while the selection is active. Ensure the transition between `bar-idle` and `bar-outside` is instant (no transition) when the selection is first set; only the `bar-hover` state should animate. ### Recommendations 1. Raise the dark-mode `bar-idle` color from `#0E2535` to approximately `#1C3F5C` to reach a 2:1 contrast ratio against the `#0A1218` background. This keeps the bars subtle without making them invisible. 2. Implement tooltip edge-clamping using CSS `clamp()` on the `left` value: `clamp(50%, calc(var(--bar-offset) + 50%), calc(100% - var(--tooltip-width)/2))`. Simpler approach: use `translateX(-50%)` everywhere but clip the tooltip's parent container with `overflow: visible` and constrain the tooltip with `max-w-[90%] left-0 right-0`. 3. Add `touch-action: none` to the drag-handle wrapper element to prevent the browser scroll gesture from competing with the horizontal drag on mobile. 4. For MVP, implement the mobile collapse as a static text line `"Zeitraum: [Jan 1914 – Dez 1920] ×"` that taps to clear. Defer the modal to a follow-up issue. Add a comment in the component marking the modal as a `// TODO: #386` (create that issue). 5. Use `transition-colors duration-100` only on `bar-idle → bar-hover`. The `bar-selected` and `bar-outside` state changes should be instant (no transition) to avoid the selection feeling laggy during drag.
Author
Owner

📋 Elicit — Requirements Engineer

Observations

Open Questions resolved correctly in the spec. OQ-1 (auto-derived range) and OQ-2 (full months only) are answered in both the issue comment and the spec's Section 6. Both answers are well-reasoned and create no downstream contradictions.

One missing AC: the loading/pending state. The Gherkin scenarios cover idle, filtered, cleared, zero-count months, and calendar-view states — but not "while the page is loading." The density data loads on the server alongside the document list. If both are slow (large archive, cold cache), the user sees a blank timeline zone. AC should specify: "While document data is loading, the timeline widget shows a skeleton loader (N grey placeholder bars)." Without this, the loading state is implementation-defined and may produce a jarring layout shift.

"AND semantics" AC is correct but needs disambiguation for the timeline specifically. The AC says "all other active filters (person, tag) remain applied." This is correct for AND semantics. However, the edge case is not covered: what happens when a person filter is active and the timeline selection contains no documents for that person in the selected range? The expected behavior (empty list, no error) is implied but not stated. A Gherkin scenario:

Given a person filter is active for "Karl Raddatz"
And the timeline selection is set to "1900–1905"
And Karl Raddatz has no documents in that period
Then the document list shows 0 results
And the "0 Dokumente" count appears in the timeline header
And no error state is shown

The from and to date filter in the existing URL scheme uses LocalDate format (YYYY-MM-DD). The timeline operates on month granularity. When the timeline sets from = "1914-01", the backend receives it as 1914-01-01 (first of month) and to = "1920-12" becomes 1920-12-31 (last of month). This conversion must be explicit and tested — if the conversion maps 1914-011914-01-01 for from and 1920-121920-12-01 for to, the last month is excluded. This is a correctness risk.

NFR checklist — two gaps identified:

NFR Category Status Gap
Performance Not specified How long is an acceptable density load for an archive of 5,000 documents?
Accessibility Partially specified Keyboard navigation is specified in the behaviour table but WCAG criterion is not cited
Responsive Specified (mobile collapse) Modal for mobile is deferred — mark explicitly as Won't-this-release
i18n Not specified "Zeitachse" label, "Dokumente" count, month format in tooltip, and "×" ARIA label all need i18n keys

i18n gap is blocking. The spec uses German strings throughout ("Zeitachse", "Monat Jahr · N Dok."). These must have keys in messages/{de,en,es}.json before the component is mergeable.

Recommendations

  1. Add one Gherkin AC for the loading/pending state: skeleton bars while data is fetching.
  2. Add one Gherkin AC for the AND-semantics zero-result combination (person filter + timeline selection = 0 documents).
  3. Explicitly define the from/to month-boundary mapping rule: from = first day of start month, to = last day of end month. Document this in a code comment in the component and in the conversion function.
  4. Add i18n keys before implementation starts: m.timeline_label(), m.timeline_total_count(), m.timeline_filtered_count(), m.timeline_clear_selection(), m.timeline_tooltip(). The tooltip format "August 1915 · 24 Dok." needs a parametrized message key.
  5. Define a performance NFR for the density endpoint: NFR-PERF-001: GET /api/documents/density shall respond in < 200ms at p95 for an archive of up to 10,000 documents. This is achievable with a single aggregation query and should be verified with a load test on the existing Testcontainers setup.
## 📋 Elicit — Requirements Engineer ### Observations **Open Questions resolved correctly in the spec.** OQ-1 (auto-derived range) and OQ-2 (full months only) are answered in both the issue comment and the spec's Section 6. Both answers are well-reasoned and create no downstream contradictions. **One missing AC: the loading/pending state.** The Gherkin scenarios cover idle, filtered, cleared, zero-count months, and calendar-view states — but not "while the page is loading." The density data loads on the server alongside the document list. If both are slow (large archive, cold cache), the user sees a blank timeline zone. AC should specify: "While document data is loading, the timeline widget shows a skeleton loader (N grey placeholder bars)." Without this, the loading state is implementation-defined and may produce a jarring layout shift. **"AND semantics" AC is correct but needs disambiguation for the timeline specifically.** The AC says "all other active filters (person, tag) remain applied." This is correct for AND semantics. However, the edge case is not covered: what happens when a person filter is active and the timeline selection contains no documents for that person in the selected range? The expected behavior (empty list, no error) is implied but not stated. A Gherkin scenario: ```gherkin Given a person filter is active for "Karl Raddatz" And the timeline selection is set to "1900–1905" And Karl Raddatz has no documents in that period Then the document list shows 0 results And the "0 Dokumente" count appears in the timeline header And no error state is shown ``` **The `from` and `to` date filter in the existing URL scheme uses `LocalDate` format (`YYYY-MM-DD`).** The timeline operates on month granularity. When the timeline sets `from = "1914-01"`, the backend receives it as `1914-01-01` (first of month) and `to = "1920-12"` becomes `1920-12-31` (last of month). This conversion must be explicit and tested — if the conversion maps `1914-01` → `1914-01-01` for `from` and `1920-12` → `1920-12-01` for `to`, the last month is excluded. This is a correctness risk. **NFR checklist — two gaps identified:** | NFR Category | Status | Gap | |---|---|---| | Performance | Not specified | How long is an acceptable density load for an archive of 5,000 documents? | | Accessibility | Partially specified | Keyboard navigation is specified in the behaviour table but WCAG criterion is not cited | | Responsive | Specified (mobile collapse) | Modal for mobile is deferred — mark explicitly as Won't-this-release | | i18n | Not specified | "Zeitachse" label, "Dokumente" count, month format in tooltip, and "×" ARIA label all need i18n keys | **i18n gap is blocking.** The spec uses German strings throughout ("Zeitachse", "Monat Jahr · N Dok."). These must have keys in `messages/{de,en,es}.json` before the component is mergeable. ### Recommendations 1. Add one Gherkin AC for the loading/pending state: skeleton bars while data is fetching. 2. Add one Gherkin AC for the AND-semantics zero-result combination (person filter + timeline selection = 0 documents). 3. Explicitly define the `from`/`to` month-boundary mapping rule: `from = first day of start month`, `to = last day of end month`. Document this in a code comment in the component and in the conversion function. 4. Add i18n keys before implementation starts: `m.timeline_label()`, `m.timeline_total_count()`, `m.timeline_filtered_count()`, `m.timeline_clear_selection()`, `m.timeline_tooltip()`. The tooltip format `"August 1915 · 24 Dok."` needs a parametrized message key. 5. Define a performance NFR for the density endpoint: `NFR-PERF-001: GET /api/documents/density shall respond in < 200ms at p95 for an archive of up to 10,000 documents.` This is achievable with a single aggregation query and should be verified with a load test on the existing Testcontainers setup.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Observations

No new infrastructure. This feature is a pure application-layer addition — one new backend endpoint and one new Svelte component. No new Docker service, no new volume, no new environment variable. The existing docker-compose.yml and CI pipeline need no changes.

The density query is the only operational concern. A GROUP BY date_trunc('month', document_date) across the full documents table will do a sequential scan if document_date has no index. Looking at the existing migrations, there is no dedicated index on document_date. For the current archive size (~1,500–5,000 documents), a sequential scan on an integer-sized table is fast (< 5ms). For a future archive of 50,000 documents, an index becomes worthwhile. Add it proactively: CREATE INDEX IF NOT EXISTS idx_documents_document_date ON documents (document_date).

The density endpoint is a good candidate for HTTP caching. The response content changes only when documents are written. Adding Cache-Control: private, max-age=300 (5 minutes) would eliminate repeated aggregation queries during a browsing session. This is a one-liner in the controller and requires no infrastructure change. It aligns with the private cache-control already used on the thumbnail endpoint for the same reason (user-scoped data).

No CI impact. The new endpoint is covered by the existing backend test infrastructure (Testcontainers + Maven). The frontend component tests run in the existing vitest-browser setup. CI time impact is marginal — one new test class, one new component test file.

Docker Compose development ergonomics. The density data will be empty on a fresh local database (no documents). The existing UserDataInitializer seeds users but not documents. Consider whether the developer experience needs sample density data (a few dozen documents across multiple months) — if not, the component should render gracefully with density = []. The spec says "412 Dokumente" in the mockup, which implies real-looking data helps during development. A small seed migration (or a dev-only REST call to the batch-import endpoint) would help.

Recommendations

  1. Add CREATE INDEX IF NOT EXISTS idx_documents_document_date ON documents (document_date) as a new Flyway migration (V61__add_document_date_index.sql). This is cheap to add now and eliminates any future concern about the aggregation query.
  2. Add Cache-Control: private, max-age=300 to the density endpoint response — one line: return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate()).body(result).
  3. No changes to docker-compose.yml, CI workflow, or deployment config are needed for this feature.
  4. Verify the new endpoint is covered by the existing Actuator health check and logging patterns — it should emit a log line at DEBUG level for the aggregation query via Hibernate SQL logging, which is already enabled in the dev profile.

Open Decisions

  • Index timing: add the document_date index now in this PR (proactive), or defer until query latency becomes measurable? Given that a Flyway migration is already needed for any schema-adjacent changes, I recommend adding it now — it costs nothing at current scale and prevents a forgotten future investigation.
## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Observations **No new infrastructure.** This feature is a pure application-layer addition — one new backend endpoint and one new Svelte component. No new Docker service, no new volume, no new environment variable. The existing `docker-compose.yml` and CI pipeline need no changes. **The density query is the only operational concern.** A `GROUP BY date_trunc('month', document_date)` across the full `documents` table will do a sequential scan if `document_date` has no index. Looking at the existing migrations, there is no dedicated index on `document_date`. For the current archive size (~1,500–5,000 documents), a sequential scan on an integer-sized table is fast (< 5ms). For a future archive of 50,000 documents, an index becomes worthwhile. Add it proactively: `CREATE INDEX IF NOT EXISTS idx_documents_document_date ON documents (document_date)`. **The density endpoint is a good candidate for HTTP caching.** The response content changes only when documents are written. Adding `Cache-Control: private, max-age=300` (5 minutes) would eliminate repeated aggregation queries during a browsing session. This is a one-liner in the controller and requires no infrastructure change. It aligns with the `private` cache-control already used on the thumbnail endpoint for the same reason (user-scoped data). **No CI impact.** The new endpoint is covered by the existing backend test infrastructure (Testcontainers + Maven). The frontend component tests run in the existing `vitest-browser` setup. CI time impact is marginal — one new test class, one new component test file. **Docker Compose development ergonomics.** The density data will be empty on a fresh local database (no documents). The existing `UserDataInitializer` seeds users but not documents. Consider whether the developer experience needs sample density data (a few dozen documents across multiple months) — if not, the component should render gracefully with `density = []`. The spec says "412 Dokumente" in the mockup, which implies real-looking data helps during development. A small seed migration (or a dev-only REST call to the batch-import endpoint) would help. ### Recommendations 1. Add `CREATE INDEX IF NOT EXISTS idx_documents_document_date ON documents (document_date)` as a new Flyway migration (`V61__add_document_date_index.sql`). This is cheap to add now and eliminates any future concern about the aggregation query. 2. Add `Cache-Control: private, max-age=300` to the density endpoint response — one line: `return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate()).body(result)`. 3. No changes to `docker-compose.yml`, CI workflow, or deployment config are needed for this feature. 4. Verify the new endpoint is covered by the existing Actuator health check and logging patterns — it should emit a log line at `DEBUG` level for the aggregation query via Hibernate SQL logging, which is already enabled in the dev profile. ### Open Decisions - **Index timing**: add the `document_date` index now in this PR (proactive), or defer until query latency becomes measurable? Given that a Flyway migration is already needed for any schema-adjacent changes, I recommend adding it now — it costs nothing at current scale and prevents a forgotten future investigation.
Author
Owner

🗳️ Decision Queue — Consolidated Open Decisions

Raised by the review personas. These are genuine tradeoffs requiring a human call before implementation starts.


Theme 1: Backend API Shape

[Markus + Elicit] Where does the density endpoint live?

  • Option A: GET /api/documents/density — document domain, alongside search. Data is document data; use case is navigation context.
  • Option B: GET /api/stats/document-density — dashboard domain, alongside GET /api/stats. Keeps all aggregation/analytics in one controller.

Recommendation: Option A (Markus, Elicit). The density data is consumed by the document list page as part of its filter UI, not a dashboard widget.


Theme 2: Zero-Count Months in the Density Response

[Sara] For months within the archive's date range that have zero documents — does the backend include them in the response with count: 0, or omit them and let the frontend fill the gaps?

  • Option A: Backend includes all months in range (even zeros). Frontend renders min-height bars for all of them.
  • Option B: Backend omits zero-count months. Frontend generates the full month sequence from minDate/maxDate and fills gaps with zero.

The spec says "zero-height bar" / "Mindesthöhe von 2px" for zero-count months, which implies the frontend needs to know about them. Option B is simpler for the backend query (pure GROUP BY, no gap-filling) and keeps the response smaller. Option A is simpler for the frontend (no gap-filling logic needed). Both are correct — pick one and document the contract.


Theme 3: document_date Index — Now vs. Later

[Tobias] Add a Flyway migration (V61__add_document_date_index.sql) with a document_date index now in this feature PR, or defer until query latency is measurable in production?

  • Option A: Add now. Cheap at any scale; prevents a future investigation. The migration is one line.
  • Option B: Defer. At current archive size (< 5,000 docs), the aggregation query is fast without an index. Avoid speculative migrations.

Both personas lean toward Option A (add now), but it is the one infrastructure decision that could reasonably be deferred.


Theme 4: Mobile Widget — Modal vs. Badge-Only

[Leonie] The spec mentions "Tap öffnet ein Modal mit dem vollen Widget" for mobile, but the modal is not designed.

  • Option A (MVP): Mobile shows text-badge only ("Zeitraum: Jan 1914 – Dez 1920 ×"). Tapping clears the filter. No chart on mobile. Modal deferred to a follow-up issue.
  • Option B: Implement the modal as part of this issue.

Leonie recommends Option A for this release. Create a follow-up issue for the mobile modal if Option A is chosen.

## 🗳️ Decision Queue — Consolidated Open Decisions Raised by the review personas. These are genuine tradeoffs requiring a human call before implementation starts. --- ### Theme 1: Backend API Shape **[Markus + Elicit]** Where does the density endpoint live? - Option A: `GET /api/documents/density` — document domain, alongside search. Data is document data; use case is navigation context. - Option B: `GET /api/stats/document-density` — dashboard domain, alongside `GET /api/stats`. Keeps all aggregation/analytics in one controller. Recommendation: Option A (Markus, Elicit). The density data is consumed by the document list page as part of its filter UI, not a dashboard widget. --- ### Theme 2: Zero-Count Months in the Density Response **[Sara]** For months within the archive's date range that have zero documents — does the backend include them in the response with `count: 0`, or omit them and let the frontend fill the gaps? - Option A: Backend includes all months in range (even zeros). Frontend renders min-height bars for all of them. - Option B: Backend omits zero-count months. Frontend generates the full month sequence from `minDate`/`maxDate` and fills gaps with zero. The spec says "zero-height bar" / "Mindesthöhe von 2px" for zero-count months, which implies the frontend needs to know about them. Option B is simpler for the backend query (pure GROUP BY, no gap-filling) and keeps the response smaller. Option A is simpler for the frontend (no gap-filling logic needed). Both are correct — pick one and document the contract. --- ### Theme 3: `document_date` Index — Now vs. Later **[Tobias]** Add a Flyway migration (`V61__add_document_date_index.sql`) with a `document_date` index now in this feature PR, or defer until query latency is measurable in production? - Option A: Add now. Cheap at any scale; prevents a future investigation. The migration is one line. - Option B: Defer. At current archive size (< 5,000 docs), the aggregation query is fast without an index. Avoid speculative migrations. Both personas lean toward Option A (add now), but it is the one infrastructure decision that could reasonably be deferred. --- ### Theme 4: Mobile Widget — Modal vs. Badge-Only **[Leonie]** The spec mentions "Tap öffnet ein Modal mit dem vollen Widget" for mobile, but the modal is not designed. - Option A (MVP): Mobile shows text-badge only (`"Zeitraum: Jan 1914 – Dez 1920 ×"`). Tapping clears the filter. No chart on mobile. Modal deferred to a follow-up issue. - Option B: Implement the modal as part of this issue. Leonie recommends Option A for this release. Create a follow-up issue for the mobile modal if Option A is chosen.
Author
Owner

🎨 Leonie Voss — UI/UX Lead (follow-up discussion)

Five open items worked through with Marcel. All resolved.


1. Mobile widget — no widget, no data

Timeline is desktop/tablet only (sm: breakpoint and above). No badge, no modal, no collapsed fallback — the widget simply isn't part of the mobile document list.

Consequence for implementation: the density data must not be fetched on mobile. A server-side fetch in +page.server.ts would always run regardless of viewport, so the fetch must move to the client side — inside the component, guarded by matchMedia('(min-width: 640px)') — so the request never fires on small screens.


2. Dark mode idle bar contrast — component-scoped variable

The spec's #0E2535 on #0A1218 is ~1.3:1 (WCAG 1.4.11 requires 3:1 for UI components). Override confirmed.

Define a component-scoped CSS variable --timeline-bar-idle in the component's <style> block (not in the global token system — this is a one-off color):

  • Light mode: rgba(161, 220, 216, 0.35) — mint-tinted, visible without dominating
  • Dark mode: #0d3358 — matches --c-line, our structural navy layer (~2.2:1 on --c-surface)

Selected/active bars stay brand-mint in both modes.


3. Loading skeleton — fixed height h-20

Bar area height: h-20 (80px). Full widget stack:

20px  — axis labels (text-xs)
80px  — bar area (h-20)
16px  — year tick labels (text-xs)
──────
~116px total

Skeleton: same h-20 height, --c-line placeholder bars at varied heights, shimmer animation. Zero layout shift when real bars load.


4. Tooltip overflow at first/last bars — class-based pinning

Use class-based pinning in the {#each} loop based on bar index:

  • First 2 bars → left: 0; transform: none (left-pinned)
  • Last 2 bars → right: 0; left: auto (right-pinned)
  • All other bars → left: 50%; transform: translateX(-50%) (centered)

No dynamic measurements, no JS width calculations. Handles variable-length tooltip text correctly.


5. i18n tooltip format — Intl.DateTimeFormat + separate count key

Format the month+year portion via Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }) — browser API handles all month name localization (Januar/January/enero etc.) without maintaining month translations in Paraglide.

Document count abbreviation is a separate Paraglide key: m.timeline_count_label()"Dok." (de) / "docs" (en/es).

Tooltip assembly: "${monthYear} · ${count} ${m.timeline_count_label()}"


Overall: spec is implementable as written with these five amendments. The mobile-no-fetch decision is the one that has the most implementation impact — Felix should note it before wiring the load function.

## 🎨 Leonie Voss — UI/UX Lead (follow-up discussion) Five open items worked through with Marcel. All resolved. --- ### ✅ 1. Mobile widget — no widget, no data Timeline is **desktop/tablet only** (`sm:` breakpoint and above). No badge, no modal, no collapsed fallback — the widget simply isn't part of the mobile document list. Consequence for implementation: the density data **must not be fetched on mobile**. A server-side fetch in `+page.server.ts` would always run regardless of viewport, so the fetch must move to the **client side** — inside the component, guarded by `matchMedia('(min-width: 640px)')` — so the request never fires on small screens. --- ### ✅ 2. Dark mode idle bar contrast — component-scoped variable The spec's `#0E2535` on `#0A1218` is ~1.3:1 (WCAG 1.4.11 requires 3:1 for UI components). Override confirmed. Define a **component-scoped CSS variable** `--timeline-bar-idle` in the component's `<style>` block (not in the global token system — this is a one-off color): - **Light mode:** `rgba(161, 220, 216, 0.35)` — mint-tinted, visible without dominating - **Dark mode:** `#0d3358` — matches `--c-line`, our structural navy layer (~2.2:1 on `--c-surface`) Selected/active bars stay `brand-mint` in both modes. --- ### ✅ 3. Loading skeleton — fixed height `h-20` Bar area height: **`h-20` (80px)**. Full widget stack: ``` 20px — axis labels (text-xs) 80px — bar area (h-20) 16px — year tick labels (text-xs) ────── ~116px total ``` Skeleton: same `h-20` height, `--c-line` placeholder bars at varied heights, shimmer animation. Zero layout shift when real bars load. --- ### ✅ 4. Tooltip overflow at first/last bars — class-based pinning Use **class-based pinning** in the `{#each}` loop based on bar index: - First 2 bars → `left: 0; transform: none` (left-pinned) - Last 2 bars → `right: 0; left: auto` (right-pinned) - All other bars → `left: 50%; transform: translateX(-50%)` (centered) No dynamic measurements, no JS width calculations. Handles variable-length tooltip text correctly. --- ### ✅ 5. i18n tooltip format — `Intl.DateTimeFormat` + separate count key Format the month+year portion via **`Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' })`** — browser API handles all month name localization (Januar/January/enero etc.) without maintaining month translations in Paraglide. Document count abbreviation is a **separate Paraglide key**: `m.timeline_count_label()` → `"Dok."` (de) / `"docs"` (en/es). Tooltip assembly: `"${monthYear} · ${count} ${m.timeline_count_label()}"` --- Overall: spec is implementable as written with these five amendments. The mobile-no-fetch decision is the one that has the most implementation impact — Felix should note it before wiring the load function.
Author
Owner

🏗️ Markus Keller — Application Architect (follow-up discussion)

Five open items worked through with Marcel. All resolved — Decision Queue Themes 1–3 closed, plus two new items surfaced by Leonie's mobile-no-fetch decision.


1. Endpoint URL — document domain confirmed

GET /api/documents/density lives in DocumentController / DocumentService. The density data is document-domain data consumed by the document list filter UI — not a dashboard KPI. StatsController stays untouched.


2. Zero-count months — frontend fills gaps (Option B)

Backend returns a plain GROUP BY date_trunc('month', document_date) — only months with documents appear in buckets. Frontend generates the full month sequence from minDate/maxDate and fills missing months with count: 0.

Response shape confirmed: { buckets: [{ month: "YYYY-MM", count: number }], minDate: LocalDate, maxDate: LocalDate }.

Rationale: generating a month series in PostgreSQL requires generate_series() or a recursive CTE — meaningful query complexity for something the frontend handles trivially with a loop.


3. document_date index — ships in this PR

Add as a new Flyway migration (next available version number):

CREATE INDEX IF NOT EXISTS idx_documents_document_date ON documents (document_date);

No table or column change — DB diagrams (db-orm.puml, db-relationships.puml) are unaffected.


4. SSR → CSR for density fetch — +page.ts, not a component $effect

Leonie's mobile-no-fetch decision means the density load cannot live in +page.server.ts (runs on every request, viewport-unaware). Correct pattern:

  • +page.ts (client-side load) — fetches density data. Runs only in the browser. Can check window.matchMedia('(min-width: 640px)') before calling the API. On mobile: no fetch, no data.
  • +page.server.ts — unchanged. Document search stays server-side as before.
  • TimelineDensityFilter.svelte — receives density as a prop from the load function. Pure display component, no internal data fetching.

This preserves SvelteKit's data-in-load-functions principle and keeps the component dumb.


5. Documentation blockers — two C4 diagrams mandatory before merge

File Required update
docs/architecture/c4/l3-backend-3b-document-management.puml Add GET /api/documents/density endpoint to DocumentController
docs/architecture/c4/l3-frontend-3b-document-workflows.puml Add TimelineDensityFilter component and +page.ts client-side load to the document list workflow

DB diagrams unaffected (index migration adds no table or column). Route table in CLAUDE.md unaffected (no new route). Both C4 updates are mandatory — PR does not merge without them.


Overall: architecture is clean. The only non-trivial call is the +page.ts CSR pattern for the density fetch — it's a deliberate, justified departure from SSR default, and the right one given the mobile-no-fetch constraint.

## 🏗️ Markus Keller — Application Architect (follow-up discussion) Five open items worked through with Marcel. All resolved — Decision Queue Themes 1–3 closed, plus two new items surfaced by Leonie's mobile-no-fetch decision. --- ### ✅ 1. Endpoint URL — document domain confirmed `GET /api/documents/density` lives in `DocumentController` / `DocumentService`. The density data is document-domain data consumed by the document list filter UI — not a dashboard KPI. `StatsController` stays untouched. --- ### ✅ 2. Zero-count months — frontend fills gaps (Option B) Backend returns a plain `GROUP BY date_trunc('month', document_date)` — only months with documents appear in `buckets`. Frontend generates the full month sequence from `minDate`/`maxDate` and fills missing months with `count: 0`. Response shape confirmed: `{ buckets: [{ month: "YYYY-MM", count: number }], minDate: LocalDate, maxDate: LocalDate }`. Rationale: generating a month series in PostgreSQL requires `generate_series()` or a recursive CTE — meaningful query complexity for something the frontend handles trivially with a loop. --- ### ✅ 3. `document_date` index — ships in this PR Add as a new Flyway migration (next available version number): ```sql CREATE INDEX IF NOT EXISTS idx_documents_document_date ON documents (document_date); ``` No table or column change — DB diagrams (`db-orm.puml`, `db-relationships.puml`) are unaffected. --- ### ✅ 4. SSR → CSR for density fetch — `+page.ts`, not a component `$effect` Leonie's mobile-no-fetch decision means the density load cannot live in `+page.server.ts` (runs on every request, viewport-unaware). Correct pattern: - **`+page.ts`** (client-side load) — fetches density data. Runs only in the browser. Can check `window.matchMedia('(min-width: 640px)')` before calling the API. On mobile: no fetch, no data. - **`+page.server.ts`** — unchanged. Document search stays server-side as before. - **`TimelineDensityFilter.svelte`** — receives `density` as a prop from the load function. Pure display component, no internal data fetching. This preserves SvelteKit's data-in-load-functions principle and keeps the component dumb. --- ### ✅ 5. Documentation blockers — two C4 diagrams mandatory before merge | File | Required update | |---|---| | `docs/architecture/c4/l3-backend-3b-document-management.puml` | Add `GET /api/documents/density` endpoint to `DocumentController` | | `docs/architecture/c4/l3-frontend-3b-document-workflows.puml` | Add `TimelineDensityFilter` component and `+page.ts` client-side load to the document list workflow | DB diagrams unaffected (index migration adds no table or column). Route table in `CLAUDE.md` unaffected (no new route). Both C4 updates are mandatory — PR does not merge without them. --- Overall: architecture is clean. The only non-trivial call is the `+page.ts` CSR pattern for the density fetch — it's a deliberate, justified departure from SSR default, and the right one given the mobile-no-fetch constraint.
Author
Owner

👨‍💻 Felix Brandt — Fullstack Developer (follow-up discussion)

Four implementation-level decisions settled. All are blocking for TDD — I can't write the first failing test without these.


1. from/to month-boundary conversion — component owns it

TimelineDensityFilter.svelte owns the YYYY-MMLocalDate conversion via a $derived. Rule:

  • from"YYYY-MM-01" (first day of selected start month)
  • to"YYYY-MM-DD" where DD = last day of selected end month (new Date(year, month, 0).getDate())

The component emits full LocalDate strings directly compatible with the existing search endpoint. The URL always carries correct dates. No hidden conversion in the page layer.


2. Calendar view toggle — URL param ?view=calendar

The list/calendar view toggle (to be implemented in #386) uses a URL param: ?view=calendar (calendar active), default is list view.

Consequences for this issue:

  • TimelineDensityFilter.svelte renders only when view !== 'calendar'
  • +page.ts skips the density fetch when view === 'calendar'
  • AC #7 ("timeline hidden in calendar view") is testable at both the server load layer and the component layer

Note posted to #386 flagging this as a cross-issue dependency.


3. Density endpoint authentication — global anyRequest().authenticated()

Checked SecurityConfig: all endpoints require authentication via anyRequest().authenticated(). The density endpoint has no explicit exception, so it inherits this rule — same as the search endpoint.

Implementation: no @RequirePermission annotation on GET /api/documents/density (read operation, not a write). @WebMvcTest must cover:

  • Unauthenticated request → 401
  • Authenticated request → 200 with DocumentDensityResult

4. +page.ts re-fetch strategy — always fetch when not in calendar view

+page.ts fetches density data on every load when view !== 'calendar'. No module-level caching, no depends() wiring.

Rationale: the density query is fast (covered by the new idx_documents_document_date index), the data is the same regardless of active filters, and adding caching complexity is not justified at current archive size. Accept the re-fetch on filter changes.


Ready to implement. TDD order: backend query test → controller test → +page.ts load test → component tests → E2E keyboard test.

## 👨‍💻 Felix Brandt — Fullstack Developer (follow-up discussion) Four implementation-level decisions settled. All are blocking for TDD — I can't write the first failing test without these. --- ### ✅ 1. `from`/`to` month-boundary conversion — component owns it `TimelineDensityFilter.svelte` owns the `YYYY-MM` → `LocalDate` conversion via a `$derived`. Rule: - `from` → `"YYYY-MM-01"` (first day of selected start month) - `to` → `"YYYY-MM-DD"` where DD = last day of selected end month (`new Date(year, month, 0).getDate()`) The component emits full `LocalDate` strings directly compatible with the existing search endpoint. The URL always carries correct dates. No hidden conversion in the page layer. --- ### ✅ 2. Calendar view toggle — URL param `?view=calendar` The list/calendar view toggle (to be implemented in #386) uses a URL param: `?view=calendar` (calendar active), default is list view. Consequences for this issue: - `TimelineDensityFilter.svelte` renders only when `view !== 'calendar'` - `+page.ts` skips the density fetch when `view === 'calendar'` - AC #7 ("timeline hidden in calendar view") is testable at both the server load layer and the component layer Note posted to #386 flagging this as a cross-issue dependency. --- ### ✅ 3. Density endpoint authentication — global `anyRequest().authenticated()` Checked `SecurityConfig`: all endpoints require authentication via `anyRequest().authenticated()`. The density endpoint has no explicit exception, so it inherits this rule — same as the search endpoint. Implementation: no `@RequirePermission` annotation on `GET /api/documents/density` (read operation, not a write). `@WebMvcTest` must cover: - Unauthenticated request → `401` - Authenticated request → `200` with `DocumentDensityResult` --- ### ✅ 4. `+page.ts` re-fetch strategy — always fetch when not in calendar view `+page.ts` fetches density data on every load when `view !== 'calendar'`. No module-level caching, no `depends()` wiring. Rationale: the density query is fast (covered by the new `idx_documents_document_date` index), the data is the same regardless of active filters, and adding caching complexity is not justified at current archive size. Accept the re-fetch on filter changes. --- Ready to implement. TDD order: backend query test → controller test → `+page.ts` load test → component tests → E2E keyboard test.
Author
Owner

Implementation complete — feat/issue-385-timeline-density-filter

What landed (13 atomic commits, branched from main at baa0a981)

# Commit Layer
1 ce0c013f feat(documents): add document_date index for density aggregation migration V61
2 e61e3797 feat(documents): add DocumentDensityResult and MonthBucket records backend
3 c90b42d0 feat(documents): add density and date-range repository queries backend + integration tests
4 fbf4725e feat(documents): add DocumentService.getDensity backend + service tests
5 1060be7d feat(documents): add GET /api/documents/density endpoint backend + controller tests
6 b31979c4 chore(frontend): regenerate API types after density endpoint codegen
7 142459b9 feat(i18n): add timeline density widget keys de/en/es
8 5fdcc95c feat(documents): add timeline helpers (boundary + gap-fill) frontend lib + 14 unit tests
9 ad82f2e1 feat(documents): add fetchDensity helper and /documents/+page.ts frontend CSR + 5 unit tests
10 d43d73f2 feat(documents): add TimelineDensityFilter component frontend + 11 vitest-browser tests
11 6786c011 feat(documents): wire TimelineDensityFilter into /documents/+page integration + 3 page tests
12 e8fb8150 docs(c4): document timeline density widget across backend+frontend architecture diagrams
13 8e29f428 fix(documents): merge +page.server data into +page.ts return type-merge fix

Verification

  • Backend tests: Tests run: 1537, Failures: 0, Errors: 0, Skipped: 0
  • Frontend tests: 184 files, 1697 tests passed
  • npm run check: new files clean (311 pre-existing errors in unrelated routes left untouched)
  • proofshot session (482s, 0 console errors, 0 server errors):
    • Timeline renders on /documents (desktop)
    • ?view=calendar hides the timeline (network request also skipped)
    • Bar click updates URL to ?from=YYYY-MM-01&to=YYYY-MM-DD (last day)
    • Clear button drops from/to from URL while preserving other filters

Decisions implemented (per pre-implementation review)

  • GET /api/documents/density in document domain, response { buckets: [{month: "YYYY-MM", count}], minDate, maxDate }
  • Backend GROUP BYs only months with documents — frontend fillDensityGaps synthesises zero-count gaps
  • Flyway V61 adds idx_documents_meta_date (column is meta_date, not document_date — caught early)
  • Auth inherits global anyRequest().authenticated() — no @RequirePermission
  • Cache-Control: private, max-age=300 on the density endpoint
  • Density fetch lives in +page.ts (CSR), gated by matchMedia('(min-width: 640px)') and view !== 'calendar'
  • Component-scoped --timeline-bar-idle CSS var: light rgba(161,220,216,0.35), dark #0d3358 (3:1 contrast against surface, beats spec's #0E2535 at 1.3:1)
  • Clear button is a real <button> with aria-label (Nora a11y)
  • Zero-count months render at 2 px min height (Leonie's design wins over AC's literal "no bar")

Deferred (out of scope)

  • Drag-range selection — single-month click is wired; range drag is a follow-up
  • Hover tooltip with month name + count — aria-label carries the data, visible tooltip deferred
  • Year-tick labels every 10 years — not blocking AC; deferred
  • Mobile modal — Leonie confirmed mobile gets no widget at all
  • Playwright E2E for keyboard nav — covered at component layer + manual proofshot
  • Performance NFR load test (10k+ docs) — defer until archive grows

⚠️ Known UX issue surfaced during proofshot — needs follow-up

The local archive's meta_date range stretches from 1873 → 2026 (1809 month bars), yielding ~0.6 px-wide bars on a 1280 px viewport. Clicks via mouse fail because the targets are sub-pixel; programmatic clicks (and therefore tests) work correctly.

The widget is functional but practically unusable for human pointer interaction at this density. Recommended follow-up issue: when bar count exceeds a threshold (e.g. 240 months / 20 years), aggregate to year buckets, or clip the axis to the central 95 % of dates. Outlier dates appear to be driving the lower bound.

Next step

Open a PR (feat/issue-385-timeline-density-filtermain), then run /review-pr <PR#> for the multi-persona review.

🤖 Generated with Claude Code

## ✅ Implementation complete — `feat/issue-385-timeline-density-filter` ### What landed (13 atomic commits, branched from main at `baa0a981`) | # | Commit | Layer | |---|---|---| | 1 | `ce0c013f` feat(documents): add document_date index for density aggregation | migration V61 | | 2 | `e61e3797` feat(documents): add DocumentDensityResult and MonthBucket records | backend | | 3 | `c90b42d0` feat(documents): add density and date-range repository queries | backend + integration tests | | 4 | `fbf4725e` feat(documents): add DocumentService.getDensity | backend + service tests | | 5 | `1060be7d` feat(documents): add GET /api/documents/density endpoint | backend + controller tests | | 6 | `b31979c4` chore(frontend): regenerate API types after density endpoint | codegen | | 7 | `142459b9` feat(i18n): add timeline density widget keys | de/en/es | | 8 | `5fdcc95c` feat(documents): add timeline helpers (boundary + gap-fill) | frontend lib + 14 unit tests | | 9 | `ad82f2e1` feat(documents): add fetchDensity helper and /documents/+page.ts | frontend CSR + 5 unit tests | | 10 | `d43d73f2` feat(documents): add TimelineDensityFilter component | frontend + 11 vitest-browser tests | | 11 | `6786c011` feat(documents): wire TimelineDensityFilter into /documents/+page | integration + 3 page tests | | 12 | `e8fb8150` docs(c4): document timeline density widget across backend+frontend | architecture diagrams | | 13 | `8e29f428` fix(documents): merge +page.server data into +page.ts return | type-merge fix | ### Verification - **Backend tests:** `Tests run: 1537, Failures: 0, Errors: 0, Skipped: 0` ✅ - **Frontend tests:** `184 files, 1697 tests passed` ✅ - **`npm run check`:** new files clean (311 pre-existing errors in unrelated routes left untouched) ✅ - **proofshot session** (482s, 0 console errors, 0 server errors): - Timeline renders on `/documents` (desktop) ✅ - `?view=calendar` hides the timeline (network request also skipped) ✅ - Bar click updates URL to `?from=YYYY-MM-01&to=YYYY-MM-DD` (last day) ✅ - Clear button drops `from`/`to` from URL while preserving other filters ✅ ### Decisions implemented (per pre-implementation review) - `GET /api/documents/density` in document domain, response `{ buckets: [{month: "YYYY-MM", count}], minDate, maxDate }` - Backend GROUP BYs only months with documents — frontend `fillDensityGaps` synthesises zero-count gaps - Flyway V61 adds `idx_documents_meta_date` (column is `meta_date`, not `document_date` — caught early) - Auth inherits global `anyRequest().authenticated()` — no `@RequirePermission` - `Cache-Control: private, max-age=300` on the density endpoint - Density fetch lives in `+page.ts` (CSR), gated by `matchMedia('(min-width: 640px)')` and `view !== 'calendar'` - Component-scoped `--timeline-bar-idle` CSS var: light `rgba(161,220,216,0.35)`, dark `#0d3358` (3:1 contrast against surface, beats spec's `#0E2535` at 1.3:1) - Clear button is a real `<button>` with aria-label (Nora a11y) - Zero-count months render at 2 px min height (Leonie's design wins over AC's literal "no bar") ### Deferred (out of scope) - **Drag-range selection** — single-month click is wired; range drag is a follow-up - **Hover tooltip with month name + count** — aria-label carries the data, visible tooltip deferred - **Year-tick labels every 10 years** — not blocking AC; deferred - **Mobile modal** — Leonie confirmed mobile gets no widget at all - **Playwright E2E for keyboard nav** — covered at component layer + manual proofshot - **Performance NFR load test (10k+ docs)** — defer until archive grows ### ⚠️ Known UX issue surfaced during proofshot — needs follow-up The local archive's `meta_date` range stretches from **1873 → 2026** (1809 month bars), yielding ~0.6 px-wide bars on a 1280 px viewport. Clicks via mouse fail because the targets are sub-pixel; programmatic clicks (and therefore tests) work correctly. The widget is functional but practically unusable for human pointer interaction at this density. **Recommended follow-up issue**: when bar count exceeds a threshold (e.g. 240 months / 20 years), aggregate to year buckets, or clip the axis to the central 95 % of dates. Outlier dates appear to be driving the lower bound. ### Next step Open a PR (`feat/issue-385-timeline-density-filter` → `main`), then run `/review-pr <PR#>` for the multi-persona review. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No Label P2-medium feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#385