feat(documents): calendar view with appointment-style document rows #386
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 list view shows documents in reverse-chronological order but gives no sense of how correspondence was distributed across days and months. A calendar grid surfaces this temporal structure and makes it easy to spot dense periods or quiet stretches.
User Story
As a family member, I want to view documents laid out in a monthly calendar grid, so that I can see which days had letters and navigate month by month through the archive.
Acceptance Criteria
Backend Note
Month navigation requires fetching documents scoped to
year + month— either via new query parameters on the existing search endpoint or a dedicated endpoint. The current offset-based pagination does not apply here; one calendar page = one calendar month.Open Questions
q3: Year and month of the earliest document
q4: yes, overflow indicator
🏛️ Markus Keller — Application Architect
Observations
/api/documents/searchendpoint's offset-based pagination doesn't fit calendar view — a calendar page is a month boundary, not a page offset. This is the right problem to name.isBetween(from, to)specification inDocumentSpecificationscan serve the calendar view directly: passfrom = first day of month,to = last day of month. No new backend infrastructure is required — this is the simplest possible approach.?from=&to=parameters on/api/documents/searchalready do the job. A dedicated endpoint would duplicate the specification chain for no architectural gain.year/monthURL parameter pair (e.g.?view=calendar&year=1923&month=03) is the correct approach to make calendar state bookmarkable and shareable. This should live in the SvelteKit route's URL, not in component state.+page.svelteand+page.server.tsfor/documentsare already 293 and 104 lines respectively. Adding a calendar toggle will push these further. ACalendarView.sveltecomponent that owns all calendar-specific state belongs insrc/lib/document/.Recommendations
from=YYYY-MM-01&to=YYYY-MM-{lastDay}via the existing?fromand?toparameters. Do not add a dedicated/api/documents/calendarendpoint — that's duplication of a specification chain that already works.?view=calendar&year=1923&month=3as query parameters. The+page.server.tsload function reads them and passesfrom/toto the existing search endpoint. Bookmarkable, SSR-compatible, and shareable.DocumentService.findEarliestDocumentDate()method that runsSELECT MIN(document_date) FROM documents WHERE document_date IS NOT NULL. Call it only when noyear/monthparam is present in the URL. Cache the result in the load response; do not make it a separate client-side fetch.CalendarView.svelteinsrc/lib/document/. The monthly grid, cell overflow logic, and hover-preview are a visual unit that should be encapsulated separately from the existingDocumentList.svelte. The/documents/+page.svelteorchestrator selects which view to render based on theviewURL param.CLAUDE.mdroute table to document that/documentsnow accepts?view=calendar.findEarliestDocumentDate()toDocumentServiceas a plain (non-@Transactional) read method returningOptional<LocalDate>.Open Decisions
👨💻 Felix Brandt — Fullstack Developer
Observations
/api/documents/searchendpoint already accepts?fromand?toasLocalDateparameters fed throughisBetween()inDocumentSpecifications. Passing first and last day of the target month is all the backend needs — no new endpoint or migration required.DocumentSearchItemwraps the fullDocumententity (includingsender,receivers,documentDate,thumbnailKey). The calendar cell rows and hover-preview card can be composed entirely from the data that the existing search result already returns.DocumentList.sveltealready groups by year usingSvelteMap— a pattern that transfers cleanly to a calendar grid that groups by day-of-month.DocumentRow.sveltealready. Wrapping it in a hover-triggered overlay is a UI composition problem, not a new data problem.+page.sveltefor/documentsis 293 lines. A calendar view adds a month navigation state machine, year/month selectors, and a full grid layout. Those need their own components, not inline markup.Recommendations
yearandmonthquery params to the existing search endpoint. InDocumentController.search(), accept two new optional@RequestParam Integer yearand@RequestParam Integer month. When both are present, computefrom = LocalDate.of(year, month, 1)andto = from.withDayOfMonth(from.lengthOfMonth())and pass them tobuildSearchSpecalongside the existingfrom/to. This avoids a new endpoint and keeps the spec chain as the single source of truth.findEarliestDocumentDate()toDocumentService:return documentRepository.findFirstByDocumentDateIsNotNullOrderByDocumentDateAsc().map(Document::getDocumentDate). Add the derived query toDocumentRepository. No new SQL needed.CalendarGrid.svelteowns the month grid (7-column CSS grid),CalendarCell.svelteowns one day cell including the document rows and overflow indicator. Both go insrc/lib/document/. The/documents/+page.svelteswitches between<DocumentList>and<CalendarGrid>based ondata.view.<div>triggered byonmouseenter/onmouseleaveon the document row within the cell. Store the hovered document's ID in a$statevariable onCalendarCell. Render the preview inline in the cell usingposition: absolute; z-index: 50. No portal, no external library.+{rest} weiteretext button. Clicking toggles a$state showAllflag on that cell — inline expansion, no modal.{#each}blocks everywhere:{#each days as day (day.iso)},{#each cellDocs as item (item.document.id)}. Do not leave any list unkeyed.$derivedfor month boundaries:const firstDay = $derived(new Date(data.year + '-' + pad(data.month) + '-01T12:00:00')). AppendT12:00:00to avoid timezone off-by-one (per project CONTRIBUTING.md date handling rule).npm run generate:apibefore writing frontend code — the newyear/monthparams must appear in the generated types.Open Decisions
🔧 Tobias Wendt — DevOps & Platform Engineer
Observations
documentstable against themeta_datecolumn. At ~1500–5000 documents and the bounded family-archive scale, a full table scan forMIN(meta_date)is fast and requires no additional index. The column is already used in sort operations.private, max-age=31536000, immutablecache headers with a?v=cache-busting param tied tothumbnailGeneratedAt. The calendar preview card can reuse those URLs directly — no new caching policy needed./documentsreturns for a single month rather than a page of 50. Response size per request will likely be smaller than the current 50-document page (most months in a 1930s family archive have far fewer than 50 letters). No payload concerns.?view=calendar&year=1923&month=3URL pattern is stateless and SSR-compatible — exactly what the SvelteKit Node adapter handles well. No additional session state.Recommendations
meta_dateto the existing composite index if query profiling shows theMIN(meta_date)scan is slow at scale. Given the archive's bounded document count, this is low priority — profile first.private, max-age=31536000, immutable) already handles the hover-preview correctly. The calendar can use the same?v={thumbnailGeneratedAt}cache-busting URL pattern without any backend change.DateAPIs and the project's existinghandleDateInput()utilities — zero new dependencies is the right call for a date grid.Open Decisions
📋 Elicit — Requirements Engineer
Observations
<select>for year, a<select>for month? A combined date picker? An<input type="month">? The acceptance criterion says "a year/month selector control" — that's wireframe-vocabulary ambiguous and could become a UX inconsistency with the rest of the UI.year/monthparams translate into a?from=&to=date range filter in the list? Or does switching to list always reset to the last non-calendar filter state? This is a real edge case users will hit.navigating.to !== nullto display a loading indicator inSearchFilterBar. The same pattern should apply to month navigation.Recommendations
<select>populated with years that have at least one dated document, and a month<select>with the 12 calendar months." This bounds the implementation and avoids a date-picker debate.from=1923-03-01andto=1923-03-31pre-populated in the date range filter." This is the most intuitive behavior and preserves context.titleattribute oraria-labelthat lists all receivers for accessibility purposes.Open Decisions
senderIdandtagfilters and switches to calendar view, the issue says filters carry through. But what year/month should be shown first? The earliest document matching those filters? The last calendar month the user was viewing? This is a minor UX judgment call with no wrong answer, but it should be decided before implementation to avoid a rebuild of the initial-month logic.🔒 Nora "NullX" Steiner — Security Engineer
Observations
/api/documents/searchcall protects this one too.yearandmonthquery parameters areIntegervalues passed toLocalDate.of(year, month, 1). This constructor throwsDateTimeExceptionif the values are out of range (e.g. month=13, year=-9999). The controller must catch this and return a 400, not a 500.{doc.title}not{@html ...}).privatecache headers — they are not publicly cacheable, which is correct. The calendar view reuses the same thumbnail URL pattern, so no new cache exposure.year/monthparameters, if not validated, could be passed as very large integers causing integer overflow inLocalDate.of(). Java'sLocalDate.of(year, month, day)throwsDateTimeExceptionfor out-of-range inputs, but the exception should be mapped to a 400 by theGlobalExceptionHandler, not a 500.Recommendations
@Min(1)/@Max(9999)validation onyearand@Min(1)/@Max(12)onmonthin the controller (using Bean Validation alongside the existing@Min/@Maxonpageandsize). This prevents any unexpected exception surface fromLocalDate.of()and gives the client a clean 400 response.GlobalExceptionHandler, add a handler forDateTimeExceptionthat maps it to a 400 with a structuredErrorCode. This is defensive coverage for any path where invalid date arithmetic reaches the JDK layer.{@html ...}in any calendar cell or preview card. All document metadata is plain text — render it with standard Svelte interpolation. If summary/transcription snippets with search highlights (the\x01/\x02delimiter pattern used in search match data) are shown in the preview, use the existing highlight renderer rather than raw HTML injection.@RequirePermissionannotation is not needed onGET /api/documents/search(it is already public-read behind the session auth wall), and the year/month extension of that endpoint inherits the same protection. Confirm there is nopermitAll()on this path inSecurityConfig.@WebMvcTesttest asserting that?year=0&month=13returns 400, not 500. This is a one-line addition toDocumentControllerTestand permanently documents the validation contract.Open Decisions
🧪 Sara Holt — QA Engineer
Observations
DocumentControllerTestandDocumentServicetest suite cover the?from/?todate range filter path. The newyear/monthparams translate tofrom/tobefore reaching the spec — the translation logic is the new unit under test, not the spec itself.findEarliestDocumentDate()call. This needs an integration test against a real PostgreSQL container (via Testcontainers) with a known dataset including one document with nodocumentDate— to verify theWHERE document_date IS NOT NULLguard works.vitest-browser-sveltetest: renderCalendarCellwith 5 documents, assert only the first 3 are visible, assert the "2 more" button exists, click it, assert all 5 are visible.CalendarCellwith one document item, assert preview is not visible, firemouseenteron the document row, assert preview becomes visible, assert it contains the document title and sender name.DocumentServiceunit test: mock the repository to return a document withdocumentDate = null, assert it does not appear when the month filter is applied. In practice this is enforced by theisBetweenspec returning anullpredicate fornulldates, so the test confirms the spec chain ignores them.?yearand?monthURL params. This needs a Playwright E2E test: navigate to/documents?view=calendar&year=1923&month=3, click "next month", assert URL becomes...month=4, assert the grid shows April 1923 headers.Recommendations
DocumentControllerTest— assertGET /api/documents/search?year=1923&month=3returns 200 and that thefrom/todates passed to the service are1923-03-01and1923-03-31.GET /api/documents/search?year=0&month=13returns 400 (covers Nora's validation recommendation).DocumentServiceIntegrationTest(Testcontainers) — seed one document withdocumentDate = nulland two with dates in March 1923, callsearchDocuments(year=1923, month=3, ...), assert exactly two results.CalendarCell— overflow: 5 items, threshold 3, assert "2 weitere" button, click, assert all visible.CalendarCell— hover preview:mouseentershows preview with correct title and sender;mouseleavehides it.+page.server.ts): importloaddirectly in Vitest, mock the API client to return an empty result for a given month, assert thatyearandmonthare present in the returned data object.AxeBuilderpass on the calendar view in the Playwright E2E suite. The grid structure (7-column CSS grid with day-of-week headers) needs<th scope="col">or equivalent ARIA roles for screen readers.Open Decisions
threshold=1). Without a fixed threshold, tests cannot assert the overflow behavior deterministically.🎨 Leonie Voss — UI/UX Design Lead
Observations
<select>with ~50 years is usable. A custom carousel or infinite scroll would be over-engineered./documentspage. Its visual treatment needs to match the project's brand pattern. A segmented control usingbrand-navyfor the active state andborder-linefor the container fits the existing design language.…(U+2026), not three periods, and the cell must usetext-overflow: ellipsiswithoverflow: hiddenandwhite-space: nowrap— otherwise long names wrap and push the cell height.min-h-[3rem]ormin-h-[2.5rem]on cells prevents the grid from collapsing unevenly when early or late cells are empty.Recommendations
sm(640px), render each week as a horizontal strip with day labels (Mon–Sun) and dot indicators for days with documents. Tapping a dot expands that day's document rows inline. This is the standard mobile calendar pattern (Google Calendar, Apple Calendar) and works at 320px.ontouchstart/onmouseenterdetection — or simply: if the device supports hover (@media (hover: hover)), use hover for preview; if not, use tap-to-expand-then-tap-to-navigate.<div role="group" aria-label="Ansicht wählen">containing two<button>elements styled as a segmented control. Active state:bg-brand-navy text-white. Inactive:bg-surface text-ink border-line. Minimum 44×44px tap target each (WCAG 2.2).<select>elements with a<label>each. Year range populated from earliest to latest document year. Month<select>uses full German month names (Januar, Februar, ...). Pair each<select>with a visible<label>— do not rely on placeholder text. This is accessible by default and requires no JavaScript date picker library.font-serif text-sm text-ink, sender/receiver infont-sans text-xs text-ink-2. UsetruncateTailwind class (which setsoverflow: hidden; white-space: nowrap; text-overflow: ellipsis) on the title and sender/receiver spans. Minimum cell font size: 12px (font-serif text-xs = 12px — acceptable for metadata, not for body text).+{n} weiterestyled asfont-sans text-xs text-primary hover:underline— same pattern as tag overflow in other list views.font-sans text-xs font-bold uppercase tracking-widest text-ink-3— matches the project's section-title pattern. Use German abbreviations: Mo, Di, Mi, Do, Fr, Sa, So.border border-brand-mintring. Otherwise no today highlight needed.<table role="grid">with<th scope="col">for day-of-week headers. This is the accessible pattern for calendar widgets (ARIA authoring practices §3.4). Each day cell is<td role="gridcell">. This enables screen-reader navigation by row and column.Open Decisions
briefwechseldate navigation could be a reference).🗂️ Decision Queue — Cross-Persona Open Items
Grouped by theme. Each item is a genuine tradeoff needing your judgment before implementation starts.
Theme A: Cell Overflow Behavior
From: Markus, Felix, Sara, Leonie
When a day cell has more documents than the display threshold, what happens when the user clicks the overflow indicator?
Sara adds: the threshold number (e.g. 3 visible before overflow) needs to be decided and written into the AC so tests can assert it deterministically. A configurable prop is fine.
Leonie's recommendation: inline expansion, consistent with the overflow pattern elsewhere in the UI.
Theme B: Mobile Calendar Layout
From: Leonie
The 7-column grid is desktop-native. On phones (320–640px), two options:
sm(640px), render a week-strip list (day labels + dot indicators for document days, tap to expand). More work, but genuinely usable at 320px and appropriate for the 60+ touch audience.Decision needed: is a scaled-down (but not redesigned) desktop grid acceptable for mobile, or is a distinct mobile layout required?
Theme C: Filter-to-Calendar Context on Initial Open
From: Elicit
When the user switches to calendar view with active
senderId/tagfilters in place, what month is shown first?findEarliestDocumentDate(filters)call. Maximally useful.MIN(meta_date)query regardless of filter state. May feel wrong if the filtered set has a much later earliest document.Elicit's recommendation: Option 1, but it requires a new service method that accepts the full filter spec. Evaluate whether the complexity is worth it for a first version.
Theme D: View-Switch Date Context (Calendar → List)
From: Elicit
When switching FROM calendar view (e.g. March 1923) TO list view:
from=1923-03-01&to=1923-03-31in the list view. Preserves the user's navigational context.Recommendation: Option 1. The
buildSearchParamsfunction in+page.sveltealready constructs URL params from a filter snapshot — addingfrom/tofrom the calendar month is a trivial addition.🔗 Dependency from #385 — View toggle state needs a decision before implementation
During the #385 developer discussion, we hit a blocker: AC #7 of #385 states "Given the user switches to calendar view, the timeline widget is hidden." This AC cannot be implemented or tested until we know how the list/calendar view toggle is represented.
The decision needed here:
?view=calendar): state survives reload, links are shareable,+page.server.tscan read it, testable at both server and component layer. The timeline in #385 checks the URL param and hides itself.$state: ephemeral, resets on reload. Testable only at the component layer. The toggle state is passed as a prop to the timeline.Recommendation: URL param (
?view=listdefault,?view=calendarwhen toggled). This is consistent with how other filter state is managed on the documents page, makes the view shareable, and gives #385's timeline a clean signal to act on.Please settle this before either #385 or #386 goes into implementation — both depend on the answer.