feat: transform home page into user dashboard #145
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?
Goal
Replace the search-only home page with a personal dashboard that orients the user on return: what needs their attention, what's new, and where they left off. Search remains available but moves to a secondary role when no filters are active.
The "resume last viewed" strip (originally #144, now merged into this issue) is implemented as part of this work.
Two-Mode Behaviour
The page has two distinct states, driven by URL search params (already available via
data.filters):q,from,to,senderId,receiverId,tagall empty)This is a conditional render — not a new route. The URL and the server load function stay as-is.
Dashboard Layout (Mobile-First)
Use
grid-cols-1 sm:grid-cols-2withgap-4for the widget row. Max 2 columns. No fixed pixel widths.Mobile — 320px, single column
Tablet — ≥ 640px (sm), 2-column widget row
Desktop — ≥ 1024px (lg), same structure with max-w-7xl breathing room
DropZone placement decision
The existing
DropZone.svelte(currently on the home page,canWriteonly) is kept and repositioned within the dashboard. It sits between the resume strip and the widget grid — third in the visual stack.Rationale: Writers visit the dashboard for two reasons: to orient (resume strip, widgets) and to upload. Placing the DropZone before the orientation widgets would bury the resume strip below the fold on mobile. Placing it after all widgets would force writers to scroll past irrelevant content on every visit. The middle position gives both audiences what they need above the fold on most devices: read-only users see the resume strip and two widgets; writers see the resume strip, the upload zone, and at least one widget.
Visibility rule: The DropZone is only rendered in dashboard mode (no active filters) and only when
data.canWriteis true — same as today.Resume Strip spec (merged from #144)
Behaviour
/documents/[id]), store the document inlocalStorage:onMountor a$effect), not on navigation away./), read the entry inonMountand render the strip if present.Visual Spec
w-full)py-3 px-4— ~48px, touch target ✓bg-surfaceborder border-line rounded-smtext-ink-3,aria-hidden) + label"Zuletzt angesehen"(text-xs font-bold uppercase tracking-widest text-ink-3) + document title (text-sm font-medium text-ink,truncate)→arrow (text-ink-3)border-accent, title →text-primaryfocus-visible:ring-2 focus-visible:ring-accentAccessibility
<a>wraps the entire strip — full touch target, keyboard navigablearia-label:"Zuletzt angesehen: [document title]"on the link elementaria-hidden="true"Widget 1 — Unread Mentions
Data source:
GET /api/notifications?type=MENTION&read=false&size=3typeandreadfilter params on the notifications endpoint (backend change)Display per item: Actor name + truncated document title + relative timestamp. Clicking navigates to the document with deep-link (
?commentId=...&annotationId=...).Rules: Max 3 items. "Alle anzeigen" →
/profile. Hide entire widget if zero unread mentions.Widget 2 — Needs Metadata
Data source:
GET /api/documents/incomplete— new lightweight endpoint returning max 3 incomplete documents (id + title). Existing/api/documents/incomplete-countcan remain for the banner fallback.Display per item: Document title (truncated) + incomplete icon + text label (color not the only cue, WCAG 1.4.1). Links to
/documents/[id]/edit.Rules: Max 3 items. "Alle anzeigen" →
/enrich. Hide entire widget if zero incomplete documents.Widget 3 — Recently Added
Data source:
GET /api/documents/searchwithstatus=ARCHIVED(or equivalent finished status), sorted bycreatedAtdescending,size=5. Requiresstatusfilter support on the search endpoint (backend change).Display per item: Document title + formatted date + sender name. Links to document detail.
Rules: Max 5 items. Always visible if any complete documents exist. No "see all" — search bar serves that purpose.
Backend Changes Required
type+readfilter paramsGET /api/notificationstype=MENTIONandread=falseGET /api/documents/incompleteincomplete-count.statusfilter to document searchGET /api/documents/searchFrontend Changes Required
+page.server.ts: parallel API calls for unread mentions and recent complete documents alongside existing calls+page.svelte: conditional render — dashboard mode vs search results modeDashboardResumeStrip.svelteDashboardMentions.svelteDashboardNeedsMetadata.svelteDashboardRecentDocuments.svelteDropZone.sveltestays as-is, repositioned in the layoutAccessibility Requirements
<section>witharia-labelledbypointing to the card headingtext-xs font-bold uppercase tracking-widest(existing card pattern)<a>bg-surface,text-ink, etc.)Implementation Checklist
Backend
typeandreadquery params toGET /api/notificationsGET /api/documents/incompleteendpoint (max N, returns id + title)statusfilter param toGET /api/documents/searchFrontend
+page.server.ts: parallel fetch for mentions + recent complete docs+page.svelte: two-mode conditional render (dashboard vs search results)DashboardResumeStrip.svelte— writelocalStoragein/documents/[id]/+page.svelteon mount; read + render on homeDashboardMentions.svelte— hides if no unread mentionsDashboardNeedsMetadata.svelte— hides if no incomplete docsDashboardRecentDocuments.svelteDropZonerepositioned between resume strip and widget grid (canWrite only, dashboard mode only)grid-cols-1 sm:grid-cols-2for Mentions + Needs Metadata rowArchitectural Review — @mkeller
Overall the spec is well-structured and the two-mode conditional render on a single route is the right call. Several things need tightening before implementation starts. I'll go through them in order of importance.
🔴 Blocker:
status=COMPLETEdoes not exist in the domain modelThe
DocumentStatusenum isPLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED. There is noCOMPLETEvalue. The "Recently Added" widget spec usesstatus=COMPLETE— this will either break at runtime or require an undocumented mapping.Decision needed before any code is written: which statuses qualify as "complete enough to surface on the dashboard"? I'd suggest
REVIEWEDandARCHIVEDboth count. Thestatusfilter on/api/documents/searchshould accept a list (status=REVIEWED&status=ARCHIVED) or a named alias. Pick one approach and document it in the issue.🔴 Blocker: Three blocking server-side fetches with no partial-failure strategy
+page.server.tswill fire three parallel API calls. If any one of them throws, SvelteKit propagates the error to the nearest+error.svelte— which means a flaky notifications service takes the entire dashboard down.Each widget call must be wrapped so a failure produces an empty result, not a page error:
This keeps widgets independent. A widget with an empty array simply doesn't render (the spec already says hide on zero results).
🟡 Design smell:
/api/documents/incompleteduplicates/api/documents/incomplete-countTwo endpoints with queries that differ only in whether they return a count or a list. This is unnecessary surface area.
Recommendation: The new
/api/documents/incompleteendpoint should return a standard paginated response — the frontend can readtotalElementsfrom it and throw away the old count endpoint once the banner fallback is updated to use the list endpoint. Don't add a third endpoint; consolidate to two then one.🟡 Design smell: "See all mentions" links to
/profileThe profile page is for account settings. An unread-mentions inbox is content, not configuration. Linking a "show me everything I was mentioned in" action to a settings page is an abstraction leak — it will confuse users and muddy the routing structure going forward.
Options, in order of preference:
/notificationsroute exists. Just omit it for now./conversationswith a filter pre-applied (?mentions=unread), if that's a sensible future direction./profileas a temporary placeholder and file a follow-up issue immediately.Don't ship the link to
/profileas a permanent destination.🟡 Streaming as an alternative to parallel blocking fetches
Since SvelteKit supports
export const loadreturning a mix of resolved and streamed data, the dashboard is a natural fit: resolve the page shell synchronously, stream widget data in. This gives the user a fast initial render and progressive enhancement of each widget.This is not a blocker — the
Promise.allSettledapproach above is correct and simpler. But if any of these queries turns out to be slow (the incomplete docs query in particular could be a full table scan), streaming is the upgrade path. Worth noting in the implementation.🟡 Dark mode tokens:
bg-surface/text-inkare not defined in this codebaseThe accessibility section references semantic tokens (
bg-surface,text-ink) that do not exist inlayout.css. The current design system usesbrand-navy,brand-mint,brand-sandwith no dark mode layer.Either: remove the dark mode requirement from this issue's scope (and file a separate
devops/uiissue for a proper token layer), or define the tokens before implementing the widgets. Implementing components against undefined utility classes will silently produce no styling in Tailwind 4.✅ Things that are right
grid-cols-1 sm:grid-cols-2— the right approach. No fixed pixel widths.aria-labelledbyon widget<section>elements — correct semantic structure.size=3/size=5caps at the API level, not filtered in the frontend — correct. Don't pull 100 records and slice in the browser.Summary
status=COMPLETEundefined in domain model/profileResolve the two blockers in the spec before implementation starts. The design smells can be addressed in the PR if they surface during implementation.
— @mkeller
Test Scope — @saraholt
This is a meaty feature. Three backend endpoint changes, three new frontend components, a conditional render mode, and non-trivial accessibility requirements. Here's the full test plan by layer.
Layer 2 — Unit Tests
Backend (
DocumentService/NotificationService)NotificationServiceTest— filtering bytype=MENTION+read=falsereturns only matching notifications; unread-only filter does not leak read notifications; type filter does not leak non-mention notifications; both filters applied together (AND semantics confirmed)DocumentServiceTest—getIncomplete()returns only documents with missing metadata;statusfilter on search delegates the correct predicate to the repository;sizecap on incomplete list is respected at the service layerFrontend (Vitest + @testing-library/svelte)
DashboardMentions.svelte— renders list when unread mentions present; hides entire widget (no DOM node, not justdisplay:none) when list is empty; truncates long document titles; relative timestamp renders; deep-link href is formed correctly (?commentId=…&annotationId=…)DashboardNeedsMetadata.svelte— hides when no incomplete docs; icon is accompanied by a text label (WCAG 1.4.1 — color not the only cue); each item links to/documents/[id]/edit, not/enrichDashboardRecentDocuments.svelte— renders title, formatted date, sender name; links to document detail page; capped at 5 itemsLayer 3 — Integration Tests
Backend (
@WebMvcTestslices)NotificationControllerTestGET /api/notifications?type=MENTION&read=false→ 200, returns only unread mentionsGET /api/notifications?type=MENTION&read=true→ 200, returns only read mentionsGET /api/notifications?read=false(no type) → 200, returns all unread regardless of typetypevalue → 400DocumentControllerTest(new endpoint)GET /api/documents/incomplete→ 200, list of{id, title}, max 3GET /api/documents/incompletewhen zero incomplete → 200, empty list (widget hides in frontend — backend returns empty, not 404)DocumentControllerTest(status filter on search)GET /api/documents/search?status=ARCHIVED→ 200, only archived documentsGET /api/documents/search?status=REVIEWED→ 200GET /api/documents/search(no status param) → 200, returns all (no regression)Backend (
@DataJpaTest+ Testcontainers — real PostgreSQL 16)Frontend (SvelteKit load function tests)
+page.server.ts— when no filters active: all three API calls are made in parallel (verify via mock call count); when any filter is active: widget API calls are NOT made (avoids wasted requests during search); API error on one widget does not crash the entire load (graceful degradation — widget data isnull, not thrown)Layer 4 — E2E Tests (Playwright)
Critical journeys only — permutations belong at the integration layer.
Dashboard mode (no active filters)
Search mode (filter active)
Responsive layout
Accessibility (axe-playwright — runs on every page visit, non-negotiable)
Run after dashboard renders and again after entering search mode. Zero violations is a hard quality gate.
Additional a11y assertions (beyond axe)
<section>witharia-labelledbywired to the heading — verify via DOM assertionboundingBox()Testability Concerns / Risks
Parallel fetch in
+page.server.ts— if this is implemented withPromise.all, a single failing API call will reject everything. The load function should usePromise.allSettledand returnnullfor failed widgets. Write the integration test for this failure mode before the implementation so it drives the right design.hide if zerorule — "no empty state" is a UI behaviour that belongs in unit tests, not just E2E. Test that zero-length data from the server results in the component not being mounted at all (checkqueryByRole('region')returnsnull, not just that nothing is visible).statusfilter on search — verify the existing search tests still pass without modification (no regression on callers that don't pass a status param).Resume strip (#144) — out of scope here, but the E2E test for the full dashboard should be written once #144 is merged, not before. Don't stub it in the E2E fixture — that hides integration bugs.
Estimated CI Impact
Total estimated CI delta: under 2 minutes. Within budget.
TDD order: write the failing
@DataJpaTestand@WebMvcTesttests first, then the service/controller code, then the Svelte unit tests, then the E2E suite. The E2E for the full dashboard should be the last thing that goes green — it's your acceptance test.