feat(documents): timeline date-range filter with density bars #385
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
Open Questions
documentDatein the database, or is it fixed (e.g. 1880–present)?Q-1: must be auto-derive
Q-2 Only full months
🏗️ Markus Keller — Application Architect
Observations
New backend endpoint needed — fits cleanly in the document domain. The spec proposes
GET /api/documents/densityreturning[{month:"1915-08", count:24}, ...]. This is read-only aggregation over thedocumentstable. It belongs inDocumentController/DocumentService, not in a new controller, because the data comes entirely from the document domain.StatsController/StatsServicehandles 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 thedocumentstable. No new table, no Flyway migration, no Redis, no message broker.The
?dateFrom/?dateTofilter already exists.DocumentController.search()already acceptsLocalDate fromandLocalDate 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 fullLocalDate(YYYY-MM-DD). For consistency, I recommend reusingLocalDateat 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 dedicatedYearMonthparameter 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
minDateandmaxDatefrom 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
GET /api/documents/densitytoDocumentController. Return a new recordDocumentDensityResult(List<MonthBucket> buckets, LocalDate minDate, LocalDate maxDate)whereMonthBucketis a record(String month, int count)with month inYYYY-MMformat.DocumentRepositoryusingdate_truncandGROUP BY. Native query is fine here — the aggregation logic is simpler and more performant as native SQL.LocalDate from/LocalDate toas optional query params on the density endpoint (consistent with the existing search endpoint). The backend maps them to month boundaries internally.@RequirePermissionneeded beyond authentication — the search endpoint is also unauthenticated-accessible (same pattern). Verify the security config covers both consistently.npm run generate:apiinfrontend/— the new response type will appear asDocumentDensityResultin TypeScript.docs/architecture/c4/l3-backend-*.pumlif 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
/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.👨💻 Felix Brandt — Fullstack Developer
Observations
Component split is well-defined in the spec.
TimelineDensityFilter.svelteis 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, andmouseupacross the document (not just the widget) — the mouse leaves the bar area during a fast drag. Use a$statebooleanisDragging+$effectto attach/detach document-level listeners during drag. Clean up in the effect's cleanup function. Do not useonMount+ manual cleanup — that's the pre-runes pattern.fromandtoasstring(YYYY-MM) or as structured{ year, month }? The existing+page.server.tsalready handlesfromandtoas ISO date strings in the URL (from=1914-01-01). The timeline operates on full months, so the natural internal representation isYearMonth = { year: number; month: number }. Convert toYYYY-MM-01andYYYY-MM-28/29/30/31before writing to the URL. A$derivedfor this conversion is clean.The spec's
MonthBucket[]needs a TypeScript type. After runningnpm run generate:api, the backend'sMonthBucketrecord 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.tsalongside the document search results. If the density endpoint fails, the page should still render the document list — returndensity: []as a graceful fallback, notthrow error(...). An emptydensityarray renders the widget with no bars, which is better than a broken page.The
buildSearchParamsfunction in+page.svelteneeds to carryfromandtofrom the timeline widget. Looking at the existing code,fromandtoare already local$statevariables fed intobuildSearchParams. The timeline widget willbind:fromandbind:toto those same state variables and calltriggerSearch()on change. No architectural change needed — just wire the existing filter state.Mobile collapse (spec section 5). The spec says
sm:block hiddenfor the chart andsm:hiddenfor 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
+page.server.tswith a parallel fetch alongside the document search:const [searchResult, densityResult] = await Promise.all([..., ...]). This avoids waterfall loading.let dragStart: number | null = $state(null)(bar index) andlet dragEnd: number | null = $state(null). DerivefromMonthandtoMonthfrom$derived. Write tofrom/toURL params only onmouseup(commit) — not on everymousemove(that would trigger a server reload on each pixel).from/toon commit. The visual selection (bar colors) updates live during drag; the document list re-fetches only on commit.{#each density as bucket, i (bucket.month)}— keyed bymonthstring, which is a stable ID.$derived:const maxCount = $derived(Math.max(...density.map(b => b.count), 1))and compute each bar's height as a percentage inline. Avoid$effectfor this.<button>withclass="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.🔒 Nora "NullX" Steiner — Security Engineer
Observations
No authentication gap on the density endpoint. The existing
GET /api/documents/searchis publicly accessible (no@RequirePermissionannotation 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. CheckSecurityConfigto confirm the current default behavior — the controller has no annotation but Spring Security'sanyRequest().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 optionalfrom/todate bounds, those must be passed as named parameters in the native query, not concatenated into the query string. The existingDocumentSpecifications.isBetween()uses the JPA Criteria API correctly for the search endpoint. Extend the same discipline to the density query: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
LocalDateby 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
SecurityConfigbefore implementing. Match the rule to the existing search endpoint — do not introduce a new asymmetry.@Query(nativeQuery = true)with@Param.<button>(not<span>) for the clear-selection×control in SearchFilterBar. Applyaria-label={m.timeline_clear_selection()}so the action is announced to screen readers.ErrorCodeneeded for the density endpoint — a missing date range is not a domain error, just an empty result. Return an emptybuckets: []withminDate: null, maxDate: nullwhen the table has no rows.🧪 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.sveltefilter 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 —@DataJpaTestor@SpringBootTestwithPostgreSQLContainer. Key test cases: zero documents, single month with documents, multi-month spread,from/tobounds 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.sveltecomponent 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 setsfromto A andtoto B, (4) clear button removes selection, (5) emptydensityarray renders widget without crashing. The drag simulation is the tricky one —vitest-browser-svelteruns in real Chromium sodispatchEvent(new MouseEvent('mousedown'))/mousemove/mouseupsequences work. Reference the existing pattern in the edit-page delete spec which was recently fixed to usedispatchEvent.Page-level integration. The
+page.svelteload function needs a test verifying that when the density endpoint fails,densityfalls back to[]and the document list still renders (graceful degradation). Test the+page.server.tsload function directly by mockingfetch— 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
DocumentDensityQueryTestusing 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.frontend/src/lib/document/TimelineDensityFilter.test.ts. Use factory:const makeDensity = (overrides = {}) => ({ month: '1915-08', count: 10, ...overrides }).+page.server.ts: when the density API returns non-ok,densityis[]and no exception propagates.showCalendarViewstate is represented (URL param? local$state?) so the "widget hidden in calendar view" AC can be tested at the right layer.Open Decisions
{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.🎨 Leonie Voss — UI/UX Lead
Observations
Spec is production-ready. The
timeline-density-filter-spec.htmlcovers 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
#0E2535for bar-idle on a#0A1218background. 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 (#D4EDE9on#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 (#1A3A52instead 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-4auf einem unsichtbaren Wrapper" — this is the right approach. Confirm the invisible wrapper hastouch-action: noneto 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. Addmax(8px, calc(50% - {barWidth/2}px))clamping or use CSSclamp()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 hiddenfor the chart andsm:hiddenfor 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
// TODOcomment in the component.The
bar-outsidedimming (#E8E6E0in 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 betweenbar-idleandbar-outsideis instant (no transition) when the selection is first set; only thebar-hoverstate should animate.Recommendations
bar-idlecolor from#0E2535to approximately#1C3F5Cto reach a 2:1 contrast ratio against the#0A1218background. This keeps the bars subtle without making them invisible.clamp()on theleftvalue:clamp(50%, calc(var(--bar-offset) + 50%), calc(100% - var(--tooltip-width)/2)). Simpler approach: usetranslateX(-50%)everywhere but clip the tooltip's parent container withoverflow: visibleand constrain the tooltip withmax-w-[90%] left-0 right-0.touch-action: noneto the drag-handle wrapper element to prevent the browser scroll gesture from competing with the horizontal drag on mobile."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).transition-colors duration-100only onbar-idle → bar-hover. Thebar-selectedandbar-outsidestate changes should be instant (no transition) to avoid the selection feeling laggy during drag.📋 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:
The
fromandtodate filter in the existing URL scheme usesLocalDateformat (YYYY-MM-DD). The timeline operates on month granularity. When the timeline setsfrom = "1914-01", the backend receives it as1914-01-01(first of month) andto = "1920-12"becomes1920-12-31(last of month). This conversion must be explicit and tested — if the conversion maps1914-01→1914-01-01forfromand1920-12→1920-12-01forto, the last month is excluded. This is a correctness risk.NFR checklist — two gaps identified:
i18n gap is blocking. The spec uses German strings throughout ("Zeitachse", "Monat Jahr · N Dok."). These must have keys in
messages/{de,en,es}.jsonbefore the component is mergeable.Recommendations
from/tomonth-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.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.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.⚙️ 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.ymland CI pipeline need no changes.The density query is the only operational concern. A
GROUP BY date_trunc('month', document_date)across the fulldocumentstable will do a sequential scan ifdocument_datehas no index. Looking at the existing migrations, there is no dedicated index ondocument_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 theprivatecache-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-browsersetup. 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
UserDataInitializerseeds 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 withdensity = []. 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
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.Cache-Control: private, max-age=300to the density endpoint response — one line:return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate()).body(result).docker-compose.yml, CI workflow, or deployment config are needed for this feature.DEBUGlevel for the aggregation query via Hibernate SQL logging, which is already enabled in the dev profile.Open Decisions
document_dateindex 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.🗳️ 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?
GET /api/documents/density— document domain, alongside search. Data is document data; use case is navigation context.GET /api/stats/document-density— dashboard domain, alongsideGET /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?minDate/maxDateand 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_dateIndex — Now vs. Later[Tobias] Add a Flyway migration (
V61__add_document_date_index.sql) with adocument_dateindex now in this feature PR, or defer until query latency is measurable in production?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.
"Zeitraum: Jan 1914 – Dez 1920 ×"). Tapping clears the filter. No chart on mobile. Modal deferred to a follow-up issue.Leonie recommends Option A for this release. Create a follow-up issue for the mobile modal if Option A is chosen.
🎨 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.tswould always run regardless of viewport, so the fetch must move to the client side — inside the component, guarded bymatchMedia('(min-width: 640px)')— so the request never fires on small screens.✅ 2. Dark mode idle bar contrast — component-scoped variable
The spec's
#0E2535on#0A1218is ~1.3:1 (WCAG 1.4.11 requires 3:1 for UI components). Override confirmed.Define a component-scoped CSS variable
--timeline-bar-idlein the component's<style>block (not in the global token system — this is a one-off color):rgba(161, 220, 216, 0.35)— mint-tinted, visible without dominating#0d3358— matches--c-line, our structural navy layer (~2.2:1 on--c-surface)Selected/active bars stay
brand-mintin both modes.✅ 3. Loading skeleton — fixed height
h-20Bar area height:
h-20(80px). Full widget stack:Skeleton: same
h-20height,--c-lineplaceholder 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:left: 0; transform: none(left-pinned)right: 0; left: auto(right-pinned)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 keyFormat 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.
🏗️ 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/densitylives inDocumentController/DocumentService. The density data is document-domain data consumed by the document list filter UI — not a dashboard KPI.StatsControllerstays 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 inbuckets. Frontend generates the full month sequence fromminDate/maxDateand fills missing months withcount: 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_dateindex — ships in this PRAdd as a new Flyway migration (next available version number):
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$effectLeonie'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 checkwindow.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— receivesdensityas 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
docs/architecture/c4/l3-backend-3b-document-management.pumlGET /api/documents/densityendpoint toDocumentControllerdocs/architecture/c4/l3-frontend-3b-document-workflows.pumlTimelineDensityFiltercomponent and+page.tsclient-side load to the document list workflowDB diagrams unaffected (index migration adds no table or column). Route table in
CLAUDE.mdunaffected (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.tsCSR pattern for the density fetch — it's a deliberate, justified departure from SSR default, and the right one given the mobile-no-fetch constraint.👨💻 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/tomonth-boundary conversion — component owns itTimelineDensityFilter.svelteowns theYYYY-MM→LocalDateconversion 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
LocalDatestrings 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=calendarThe 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.svelterenders only whenview !== 'calendar'+page.tsskips the density fetch whenview === 'calendar'Note posted to #386 flagging this as a cross-issue dependency.
✅ 3. Density endpoint authentication — global
anyRequest().authenticated()Checked
SecurityConfig: all endpoints require authentication viaanyRequest().authenticated(). The density endpoint has no explicit exception, so it inherits this rule — same as the search endpoint.Implementation: no
@RequirePermissionannotation onGET /api/documents/density(read operation, not a write).@WebMvcTestmust cover:401200withDocumentDensityResult✅ 4.
+page.tsre-fetch strategy — always fetch when not in calendar view+page.tsfetches density data on every load whenview !== 'calendar'. No module-level caching, nodepends()wiring.Rationale: the density query is fast (covered by the new
idx_documents_document_dateindex), 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.tsload test → component tests → E2E keyboard test.✅ Implementation complete —
feat/issue-385-timeline-density-filterWhat landed (13 atomic commits, branched from main at
baa0a981)ce0c013ffeat(documents): add document_date index for density aggregatione61e3797feat(documents): add DocumentDensityResult and MonthBucket recordsc90b42d0feat(documents): add density and date-range repository queriesfbf4725efeat(documents): add DocumentService.getDensity1060be7dfeat(documents): add GET /api/documents/density endpointb31979c4chore(frontend): regenerate API types after density endpoint142459b9feat(i18n): add timeline density widget keys5fdcc95cfeat(documents): add timeline helpers (boundary + gap-fill)ad82f2e1feat(documents): add fetchDensity helper and /documents/+page.tsd43d73f2feat(documents): add TimelineDensityFilter component6786c011feat(documents): wire TimelineDensityFilter into /documents/+pagee8fb8150docs(c4): document timeline density widget across backend+frontend8e29f428fix(documents): merge +page.server data into +page.ts returnVerification
Tests run: 1537, Failures: 0, Errors: 0, Skipped: 0✅184 files, 1697 tests passed✅npm run check: new files clean (311 pre-existing errors in unrelated routes left untouched) ✅/documents(desktop) ✅?view=calendarhides the timeline (network request also skipped) ✅?from=YYYY-MM-01&to=YYYY-MM-DD(last day) ✅from/tofrom URL while preserving other filters ✅Decisions implemented (per pre-implementation review)
GET /api/documents/densityin document domain, response{ buckets: [{month: "YYYY-MM", count}], minDate, maxDate }fillDensityGapssynthesises zero-count gapsidx_documents_meta_date(column ismeta_date, notdocument_date— caught early)anyRequest().authenticated()— no@RequirePermissionCache-Control: private, max-age=300on the density endpoint+page.ts(CSR), gated bymatchMedia('(min-width: 640px)')andview !== 'calendar'--timeline-bar-idleCSS var: lightrgba(161,220,216,0.35), dark#0d3358(3:1 contrast against surface, beats spec's#0E2535at 1.3:1)<button>with aria-label (Nora a11y)Deferred (out of scope)
⚠️ Known UX issue surfaced during proofshot — needs follow-up
The local archive's
meta_daterange 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