feat: transform home page into user dashboard #145

Closed
opened 2026-03-28 11:44:28 +01:00 by marcel · 2 comments
Owner

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):

State Condition What renders
Dashboard No active filters (q, from, to, senderId, receiverId, tag all empty) Search bar + resume strip + DropZone (writers) + widget grid
Search Any filter is active Search bar + document list (current behaviour)

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-2 with gap-4 for the widget row. Max 2 columns. No fixed pixel widths.

Mobile — 320px, single column

┌──────────────────────────────────────┐
│  🔍 Suchen...              [▾ Filter] │
├──────────────────────────────────────┤
│  ↩ Zuletzt: Großmutters Brief     →  │  ← resume strip (conditional, localStorage)
├──────────────────────────────────────┤
│  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │
│      ↑  Dateien hierher ziehen        │  ← DropZone (canWrite users only)
│  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
├──────────────────────────────────────┤
│  UNREAD MENTIONS                     │
│  Anna @ Großmutters Brief    2h ago  │
│  Klaus @ Reisepass 1955     14h ago  │
│  Maria @ Heiratsurkunde      1d ago  │
│  Alle anzeigen →                     │
├──────────────────────────────────────┤
│  NEEDS METADATA                      │
│  ⚠ Scan001.pdf                       │
│  ⚠ Brief ohne Datum                  │
│  ⚠ Unbekanntes Dokument              │
│  Alle anzeigen →                     │
├──────────────────────────────────────┤
│  RECENTLY ADDED                      │
│  Heiratsurkunde · 12. März 1952      │
│  Reisepass · 1955 · Klaus Müller     │
│  Brief · 3. Juni 1943 · Oma          │
│  Zeugnis · 1938 · Paul Raddatz       │
│  Foto · ca. 1960 · unbekannt         │
└──────────────────────────────────────┘

Tablet — ≥ 640px (sm), 2-column widget row

┌────────────────────────────────────────────────────────┐
│  🔍 Suchen...                             [▾ Filter]   │
├────────────────────────────────────────────────────────┤
│  ↩ Zuletzt angesehen: Großmutters Brief             →  │
├────────────────────────────────────────────────────────┤
│  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐  │
│        ↑  Dateien hierher ziehen oder auswählen         │  ← DropZone (canWrite)
│  └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘  │
├───────────────────────────┬────────────────────────────┤
│  UNREAD MENTIONS          │  NEEDS METADATA            │
│  Anna @ Brief      2h ago │  ⚠ Scan001.pdf             │
│  Klaus @ Pass     14h ago │  ⚠ Brief ohne Datum        │
│  Maria @ Urk.      1d ago │  ⚠ Unbekanntes Dok.        │
│  Alle anzeigen →          │  Alle anzeigen →           │
├───────────────────────────┴────────────────────────────┤
│  RECENTLY ADDED                                        │
│  Heiratsurkunde · 12. März 1952 · Anna Raddatz         │
│  Reisepass · 1955 · Klaus Müller                       │
│  Brief · 3. Juni 1943 · Oma                            │
│  Zeugnis · 1938 · Paul Raddatz                         │
│  Foto · ca. 1960 · unbekannt                           │
└────────────────────────────────────────────────────────┘

Desktop — ≥ 1024px (lg), same structure with max-w-7xl breathing room

┌──────────────────────────────────────────────────────────────────────────────┐
│  🔍 Suchen...                                               [▾ Filter]       │
├──────────────────────────────────────────────────────────────────────────────┤
│  ↩ Zuletzt angesehen: Großmutters Brief, 3. Juni 1943                     →  │
├──────────────────────────────────────────────────────────────────────────────┤
│  ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐  │
│           ↑  Dateien hierher ziehen oder auswählen (canWrite only)            │
│  └ ─ ─ ─ ─ ��� ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘  │
├─────────────────────────────────────┬────────────────────────────────────────┤
│  UNREAD MENTIONS                    │  NEEDS METADATA                        │
│  Anna @ Großmutters Brief   2h ago  │  ⚠ Scan001.pdf                         │
│  Klaus @ Reisepass 1955    14h ago  │  ⚠ Brief ohne Datum                    │
│  Maria @ Heiratsurkunde     1d ago  │  ⚠ Unbekanntes Dokument                │
│  Alle anzeigen →                    │  Alle anzeigen →                       │
├─────────────────────────────────────┴────────────────────────────────────────┤
│  RECENTLY ADDED                                                              │
│  Heiratsurkunde · 12. März 1952 · Anna Raddatz                               │
│  Reisepass · 1955 · Klaus Müller                                             │
│  Brief · 3. Juni 1943 · Oma                                                  │
│  Zeugnis · 1938 · Paul Raddatz                                               │
│  Foto · ca. 1960 · unbekannt                                                 │
└──────────────────────────────────────────────────────────────────────────────┘

DropZone placement decision

The existing DropZone.svelte (currently on the home page, canWrite only) 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.canWrite is true — same as today.


Resume Strip spec (merged from #144)

Behaviour

  • Write: When the user visits any document detail page (/documents/[id]), store the document in localStorage:
    localStorage.setItem('lastViewedDocument', JSON.stringify({ id, title, documentDate }));
    
    Write on page load (in onMount or a $effect), not on navigation away.
  • Read: On the home page (/), read the entry in onMount and render the strip if present.
  • Suppression: Hidden when any search filter is active (strip is a dashboard element, not a search result).
  • Stale entry: No expiry for v1 — entry persists until overwritten by a newer document visit.
  • Edge case: Do not suppress the strip if the document also appears in Recently Added.

Visual Spec

Property Value
Width Full-width (w-full)
Height py-3 px-4 — ~48px, touch target ✓
Background bg-surface
Border border border-line rounded-sm
Left content Clock icon (16×16, text-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)
Right content arrow (text-ink-3)
Hover border-accent, title → text-primary
Focus focus-visible:ring-2 focus-visible:ring-accent
Dark mode Semantic tokens only

Accessibility

  • <a> wraps the entire strip — full touch target, keyboard navigable
  • aria-label: "Zuletzt angesehen: [document title]" on the link element
  • Clock icon: aria-hidden="true"

Widget 1 — Unread Mentions

Data source: GET /api/notifications?type=MENTION&read=false&size=3

  • Requires type and read filter 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-count can 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/search with status=ARCHIVED (or equivalent finished status), sorted by createdAt descending, size=5. Requires status filter 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

Change Endpoint Details
Add type + read filter params GET /api/notifications Filter by type=MENTION and read=false
New incomplete documents list GET /api/documents/incomplete Returns max N docs with missing metadata (id, title). Reuse query from incomplete-count.
Add status filter to document search GET /api/documents/search Allow filtering by document status

Frontend 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 mode
  • New components:
    • DashboardResumeStrip.svelte
    • DashboardMentions.svelte
    • DashboardNeedsMetadata.svelte
    • DashboardRecentDocuments.svelte
  • DropZone.svelte stays as-is, repositioned in the layout

Accessibility Requirements

  • Each widget card: <section> with aria-labelledby pointing to the card heading
  • All widget headings: text-xs font-bold uppercase tracking-widest (existing card pattern)
  • Touch targets: all list items ≥ 44×44px — wrap each item in a full-width <a>
  • Color must never be the only cue — pair status indicator with icon + text label
  • Dark mode: semantic tokens only (bg-surface, text-ink, etc.)

Implementation Checklist

Backend

  • Add type and read query params to GET /api/notifications
  • Add GET /api/documents/incomplete endpoint (max N, returns id + title)
  • Add status filter param to GET /api/documents/search
  • Tests for all three changes

Frontend

  • +page.server.ts: parallel fetch for mentions + recent complete docs
  • +page.svelte: two-mode conditional render (dashboard vs search results)
  • DashboardResumeStrip.svelte — write localStorage in /documents/[id]/+page.svelte on mount; read + render on home
  • DashboardMentions.svelte — hides if no unread mentions
  • DashboardNeedsMetadata.svelte — hides if no incomplete docs
  • DashboardRecentDocuments.svelte
  • DropZone repositioned between resume strip and widget grid (canWrite only, dashboard mode only)
  • Layout: grid-cols-1 sm:grid-cols-2 for Mentions + Needs Metadata row
  • All dashboard elements hidden when any search filter is active
  • Touch targets ≥ 44px verified at 320px viewport
  • Dark mode verified
  • No widget renders an empty state — conditional render only
## 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`): | State | Condition | What renders | |---|---|---| | **Dashboard** | No active filters (`q`, `from`, `to`, `senderId`, `receiverId`, `tag` all empty) | Search bar + resume strip + DropZone (writers) + widget grid | | **Search** | Any filter is active | Search bar + document list (current behaviour) | 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-2` with `gap-4` for the widget row. Max 2 columns. No fixed pixel widths. ### Mobile — 320px, single column ``` ┌──────────────────────────────────────┐ │ 🔍 Suchen... [▾ Filter] │ ├──────────────────────────────────────┤ │ ↩ Zuletzt: Großmutters Brief → │ ← resume strip (conditional, localStorage) ├──────────────────────────────────────┤ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ │ ↑ Dateien hierher ziehen │ ← DropZone (canWrite users only) │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ ├──────────────────────────────────────┤ │ UNREAD MENTIONS │ │ Anna @ Großmutters Brief 2h ago │ │ Klaus @ Reisepass 1955 14h ago │ │ Maria @ Heiratsurkunde 1d ago │ │ Alle anzeigen → │ ├──────────────────────────────────────┤ │ NEEDS METADATA │ │ ⚠ Scan001.pdf │ │ ⚠ Brief ohne Datum │ │ ⚠ Unbekanntes Dokument │ │ Alle anzeigen → │ ├──────────────────────────────────────┤ │ RECENTLY ADDED │ │ Heiratsurkunde · 12. März 1952 │ │ Reisepass · 1955 · Klaus Müller │ │ Brief · 3. Juni 1943 · Oma │ │ Zeugnis · 1938 · Paul Raddatz │ │ Foto · ca. 1960 · unbekannt │ └──────────────────────────────────────┘ ``` ### Tablet — ≥ 640px (sm), 2-column widget row ``` ┌────────────────────────────────────────────────────────┐ │ 🔍 Suchen... [▾ Filter] │ ├────────────────────────────────────────────────────────┤ │ ↩ Zuletzt angesehen: Großmutters Brief → │ ├────────────────────────────────────────────────────────┤ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ │ ↑ Dateien hierher ziehen oder auswählen │ ← DropZone (canWrite) │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ ├───────────────────────────┬────────────────────────────┤ │ UNREAD MENTIONS │ NEEDS METADATA │ │ Anna @ Brief 2h ago │ ⚠ Scan001.pdf │ │ Klaus @ Pass 14h ago │ ⚠ Brief ohne Datum │ │ Maria @ Urk. 1d ago │ ⚠ Unbekanntes Dok. │ │ Alle anzeigen → │ Alle anzeigen → │ ├───────────────────────────┴────────────────────────────┤ │ RECENTLY ADDED │ │ Heiratsurkunde · 12. März 1952 · Anna Raddatz │ │ Reisepass · 1955 · Klaus Müller │ │ Brief · 3. Juni 1943 · Oma │ │ Zeugnis · 1938 · Paul Raddatz │ │ Foto · ca. 1960 · unbekannt │ └────────────────────────────────────────────────────────┘ ``` ### Desktop — ≥ 1024px (lg), same structure with max-w-7xl breathing room ``` ┌──────────────────────────────────────────────────────────────────────────────┐ │ 🔍 Suchen... [▾ Filter] │ ├──────────────────────────────────────────────────────────────────────────────┤ │ ↩ Zuletzt angesehen: Großmutters Brief, 3. Juni 1943 → │ ├──────────────────────────────────────────────────────────────────────────────┤ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ │ ↑ Dateien hierher ziehen oder auswählen (canWrite only) │ │ └ ─ ─ ─ ─ ��� ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ ├─────────────────────────────────────┬────────────────────────────────────────┤ │ UNREAD MENTIONS │ NEEDS METADATA │ │ Anna @ Großmutters Brief 2h ago │ ⚠ Scan001.pdf │ │ Klaus @ Reisepass 1955 14h ago │ ⚠ Brief ohne Datum │ │ Maria @ Heiratsurkunde 1d ago │ ⚠ Unbekanntes Dokument │ │ Alle anzeigen → │ Alle anzeigen → │ ├─────────────────────────────────────┴────────────────────────────────────────┤ │ RECENTLY ADDED │ │ Heiratsurkunde · 12. März 1952 · Anna Raddatz │ │ Reisepass · 1955 · Klaus Müller │ │ Brief · 3. Juni 1943 · Oma │ │ Zeugnis · 1938 · Paul Raddatz │ │ Foto · ca. 1960 · unbekannt │ └──────────────────────────────────────────────────────────────────────────────┘ ``` --- ## DropZone placement decision The existing `DropZone.svelte` (currently on the home page, `canWrite` only) 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.canWrite` is true — same as today. --- ## Resume Strip spec (merged from #144) ### Behaviour - **Write**: When the user visits any document detail page (`/documents/[id]`), store the document in `localStorage`: ```ts localStorage.setItem('lastViewedDocument', JSON.stringify({ id, title, documentDate })); ``` Write on page load (in `onMount` or a `$effect`), not on navigation away. - **Read**: On the home page (`/`), read the entry in `onMount` and render the strip if present. - **Suppression**: Hidden when any search filter is active (strip is a dashboard element, not a search result). - **Stale entry**: No expiry for v1 — entry persists until overwritten by a newer document visit. - **Edge case**: Do **not** suppress the strip if the document also appears in Recently Added. ### Visual Spec | Property | Value | |---|---| | Width | Full-width (`w-full`) | | Height | `py-3 px-4` — ~48px, touch target ✓ | | Background | `bg-surface` | | Border | `border border-line rounded-sm` | | Left content | Clock icon (16×16, `text-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`) | | Right content | `→` arrow (`text-ink-3`) | | Hover | `border-accent`, title → `text-primary` | | Focus | `focus-visible:ring-2 focus-visible:ring-accent` | | Dark mode | Semantic tokens only | ### Accessibility - `<a>` wraps the entire strip — full touch target, keyboard navigable - `aria-label`: `"Zuletzt angesehen: [document title]"` on the link element - Clock icon: `aria-hidden="true"` --- ## Widget 1 — Unread Mentions **Data source:** `GET /api/notifications?type=MENTION&read=false&size=3` - Requires `type` and `read` filter 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-count` can 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/search` with `status=ARCHIVED` (or equivalent finished status), sorted by `createdAt` descending, `size=5`. Requires `status` filter 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 | Change | Endpoint | Details | |---|---|---| | Add `type` + `read` filter params | `GET /api/notifications` | Filter by `type=MENTION` and `read=false` | | New incomplete documents list | `GET /api/documents/incomplete` | Returns max N docs with missing metadata (id, title). Reuse query from `incomplete-count`. | | Add `status` filter to document search | `GET /api/documents/search` | Allow filtering by document status | --- ## Frontend 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 mode - New components: - `DashboardResumeStrip.svelte` - `DashboardMentions.svelte` - `DashboardNeedsMetadata.svelte` - `DashboardRecentDocuments.svelte` - `DropZone.svelte` stays as-is, repositioned in the layout --- ## Accessibility Requirements - Each widget card: `<section>` with `aria-labelledby` pointing to the card heading - All widget headings: `text-xs font-bold uppercase tracking-widest` (existing card pattern) - Touch targets: all list items ≥ 44×44px — wrap each item in a full-width `<a>` - Color must never be the only cue — pair status indicator with icon + text label - Dark mode: semantic tokens only (`bg-surface`, `text-ink`, etc.) --- ## Implementation Checklist ### Backend - [ ] Add `type` and `read` query params to `GET /api/notifications` - [ ] Add `GET /api/documents/incomplete` endpoint (max N, returns id + title) - [ ] Add `status` filter param to `GET /api/documents/search` - [ ] Tests for all three changes ### Frontend - [ ] `+page.server.ts`: parallel fetch for mentions + recent complete docs - [ ] `+page.svelte`: two-mode conditional render (dashboard vs search results) - [ ] `DashboardResumeStrip.svelte` — write `localStorage` in `/documents/[id]/+page.svelte` on mount; read + render on home - [ ] `DashboardMentions.svelte` — hides if no unread mentions - [ ] `DashboardNeedsMetadata.svelte` — hides if no incomplete docs - [ ] `DashboardRecentDocuments.svelte` - [ ] `DropZone` repositioned between resume strip and widget grid (canWrite only, dashboard mode only) - [ ] Layout: `grid-cols-1 sm:grid-cols-2` for Mentions + Needs Metadata row - [ ] All dashboard elements hidden when any search filter is active - [ ] Touch targets ≥ 44px verified at 320px viewport - [ ] Dark mode verified - [ ] No widget renders an empty state — conditional render only
marcel added the featureui labels 2026-03-28 11:44:32 +01:00
Author
Owner

Architectural 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=COMPLETE does not exist in the domain model

The DocumentStatus enum is PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED. There is no COMPLETE value. The "Recently Added" widget spec uses status=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 REVIEWED and ARCHIVED both count. The status filter on /api/documents/search should 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.ts will 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:

// +page.server.ts
const [mentions, incomplete, recent] = await Promise.allSettled([
    api.GET('/api/notifications', { params: { query: { type: 'MENTION', read: false, size: 3 } } }),
    api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
    api.GET('/api/documents/search', { params: { query: { status: ['REVIEWED', 'ARCHIVED'], size: 5 } } })
]);

return {
    // existing search data...
    mentions: mentions.status === 'fulfilled' && mentions.value.response.ok ? mentions.value.data! : [],
    incomplete: incomplete.status === 'fulfilled' && incomplete.value.response.ok ? incomplete.value.data! : [],
    recentDocuments: recent.status === 'fulfilled' && recent.value.response.ok ? recent.value.data! : [],
};

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/incomplete duplicates /api/documents/incomplete-count

Two 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/incomplete endpoint should return a standard paginated response — the frontend can read totalElements from 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.


The 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:

  1. Defer the "See all" link until a proper /notifications route exists. Just omit it for now.
  2. Link to /conversations with a filter pre-applied (?mentions=unread), if that's a sensible future direction.
  3. Accept /profile as a temporary placeholder and file a follow-up issue immediately.

Don't ship the link to /profile as a permanent destination.


🟡 Streaming as an alternative to parallel blocking fetches

Since SvelteKit supports export const load returning 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.allSettled approach 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-ink are not defined in this codebase

The accessibility section references semantic tokens (bg-surface, text-ink) that do not exist in layout.css. The current design system uses brand-navy, brand-mint, brand-sand with no dark mode layer.

Either: remove the dark mode requirement from this issue's scope (and file a separate devops/ui issue 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

  • Single route, conditional render — correct. A second route would duplicate the search bar and split the URL namespace for no gain.
  • grid-cols-1 sm:grid-cols-2 — the right approach. No fixed pixel widths.
  • Hiding widgets on zero results (not rendering an empty state) — clean. Avoids the awkward "nothing to show" card.
  • WCAG 1.4.1 paired icon+text for status indicators — explicitly called out. Good.
  • aria-labelledby on widget <section> elements — correct semantic structure.
  • size=3 / size=5 caps at the API level, not filtered in the frontend — correct. Don't pull 100 records and slice in the browser.

Summary

# Finding Severity
1 status=COMPLETE undefined in domain model 🔴 Blocker
2 No partial-failure strategy for parallel loads 🔴 Blocker
3 Duplicate incomplete-count and incomplete-list endpoints 🟡 Design smell
4 "See all" mentions links to /profile 🟡 Design smell
5 Streaming as upgrade path for slow queries 🟡 Note
6 Dark mode tokens undefined in codebase 🟡 Scope issue

Resolve the two blockers in the spec before implementation starts. The design smells can be addressed in the PR if they surface during implementation.

— @mkeller

**Architectural 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=COMPLETE` does not exist in the domain model The `DocumentStatus` enum is `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`. There is no `COMPLETE` value. The "Recently Added" widget spec uses `status=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 `REVIEWED` and `ARCHIVED` both count. The `status` filter on `/api/documents/search` should 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.ts` will 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: ```typescript // +page.server.ts const [mentions, incomplete, recent] = await Promise.allSettled([ api.GET('/api/notifications', { params: { query: { type: 'MENTION', read: false, size: 3 } } }), api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }), api.GET('/api/documents/search', { params: { query: { status: ['REVIEWED', 'ARCHIVED'], size: 5 } } }) ]); return { // existing search data... mentions: mentions.status === 'fulfilled' && mentions.value.response.ok ? mentions.value.data! : [], incomplete: incomplete.status === 'fulfilled' && incomplete.value.response.ok ? incomplete.value.data! : [], recentDocuments: recent.status === 'fulfilled' && recent.value.response.ok ? recent.value.data! : [], }; ``` 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/incomplete` duplicates `/api/documents/incomplete-count` Two 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/incomplete` endpoint should return a standard paginated response — the frontend can read `totalElements` from 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 `/profile` The 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: 1. **Defer the "See all" link** until a proper `/notifications` route exists. Just omit it for now. 2. Link to `/conversations` with a filter pre-applied (`?mentions=unread`), if that's a sensible future direction. 3. Accept `/profile` as a temporary placeholder and file a follow-up issue immediately. Don't ship the link to `/profile` as a permanent destination. --- ### 🟡 Streaming as an alternative to parallel blocking fetches Since SvelteKit supports `export const load` returning 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.allSettled` approach 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-ink` are not defined in this codebase The accessibility section references semantic tokens (`bg-surface`, `text-ink`) that do not exist in `layout.css`. The current design system uses `brand-navy`, `brand-mint`, `brand-sand` with no dark mode layer. Either: remove the dark mode requirement from this issue's scope (and file a separate `devops`/`ui` issue 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 - **Single route, conditional render** — correct. A second route would duplicate the search bar and split the URL namespace for no gain. - **`grid-cols-1 sm:grid-cols-2`** — the right approach. No fixed pixel widths. - **Hiding widgets on zero results** (not rendering an empty state) — clean. Avoids the awkward "nothing to show" card. - **WCAG 1.4.1 paired icon+text for status indicators** — explicitly called out. Good. - **`aria-labelledby` on widget `<section>` elements** — correct semantic structure. - **`size=3` / `size=5` caps at the API level**, not filtered in the frontend — correct. Don't pull 100 records and slice in the browser. --- ### Summary | # | Finding | Severity | |---|---|---| | 1 | `status=COMPLETE` undefined in domain model | 🔴 Blocker | | 2 | No partial-failure strategy for parallel loads | 🔴 Blocker | | 3 | Duplicate incomplete-count and incomplete-list endpoints | 🟡 Design smell | | 4 | "See all" mentions links to `/profile` | 🟡 Design smell | | 5 | Streaming as upgrade path for slow queries | 🟡 Note | | 6 | Dark mode tokens undefined in codebase | 🟡 Scope issue | Resolve the two blockers in the spec before implementation starts. The design smells can be addressed in the PR if they surface during implementation. — @mkeller
Author
Owner

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 by type=MENTION + read=false returns 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)
  • DocumentServiceTestgetIncomplete() returns only documents with missing metadata; status filter on search delegates the correct predicate to the repository; size cap on incomplete list is respected at the service layer

Frontend (Vitest + @testing-library/svelte)

  • DashboardMentions.svelte — renders list when unread mentions present; hides entire widget (no DOM node, not just display: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 /enrich
  • DashboardRecentDocuments.svelte — renders title, formatted date, sender name; links to document detail page; capped at 5 items

Layer 3 — Integration Tests

Backend (@WebMvcTest slices)

  • NotificationControllerTest

    • GET /api/notifications?type=MENTION&read=false → 200, returns only unread mentions
    • GET /api/notifications?type=MENTION&read=true → 200, returns only read mentions
    • GET /api/notifications?read=false (no type) → 200, returns all unread regardless of type
    • Unknown type value → 400
    • Unauthenticated request → 401
  • DocumentControllerTest (new endpoint)

    • GET /api/documents/incomplete → 200, list of {id, title}, max 3
    • GET /api/documents/incomplete when zero incomplete → 200, empty list (widget hides in frontend — backend returns empty, not 404)
    • Unauthenticated → 401
  • DocumentControllerTest (status filter on search)

    • GET /api/documents/search?status=ARCHIVED → 200, only archived documents
    • GET /api/documents/search?status=REVIEWED → 200
    • GET /api/documents/search (no status param) → 200, returns all (no regression)
    • Invalid status value → 400

Backend (@DataJpaTest + Testcontainers — real PostgreSQL 16)

  • Repository-level query for incomplete documents: fixtures with mixed complete/incomplete rows; verify count and content match
  • Notification repository: fixtures with mixed type/read combinations; verify all four filter permutations return the correct subset

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 is null, not thrown)

Layer 4 — E2E Tests (Playwright)

Critical journeys only — permutations belong at the integration layer.

Dashboard mode (no active filters)

test('dashboard renders all three widgets when no filters active')
test('clicking a mention item navigates to document with deep-link params')
test('clicking a needs-metadata item navigates to /documents/[id]/edit')
test('clicking a recently-added item navigates to document detail')
test('dashboard widgets disappear when user types in search bar')

Search mode (filter active)

test('document list renders and widgets are absent when search query present')
test('clearing search query restores dashboard widgets')

Responsive layout

test('at 320px widgets are stacked single-column')
test('at 768px top two widgets are side-by-side (grid-cols-2)')

Accessibility (axe-playwright — runs on every page visit, non-negotiable)

await checkA11y(page, undefined, { detailedReport: true, detailedReportOptions: { html: true } });

Run after dashboard renders and again after entering search mode. Zero violations is a hard quality gate.

Additional a11y assertions (beyond axe)

  • Each widget card is a <section> with aria-labelledby wired to the heading — verify via DOM assertion
  • All list item links have a touch target ≥ 44×44px at 320px viewport — verify via boundingBox()
  • Dark mode: visual regression snapshot at 320px, 768px, 1440px × light/dark — Leonie reviews diffs before merge

Testability Concerns / Risks

  1. Parallel fetch in +page.server.ts — if this is implemented with Promise.all, a single failing API call will reject everything. The load function should use Promise.allSettled and return null for failed widgets. Write the integration test for this failure mode before the implementation so it drives the right design.

  2. hide if zero rule — "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 (check queryByRole('region') returns null, not just that nothing is visible).

  3. status filter on search — verify the existing search tests still pass without modification (no regression on callers that don't pass a status param).

  4. 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

Layer Delta
Unit +~12 test cases, negligible time
Integration (backend) +~14 test cases, ~20-30s (Testcontainers already warm)
Integration (frontend load) +~4 test cases, negligible
E2E +~8 test cases, estimated +60-90s
Visual regression +6 snapshots (3 breakpoints × 2 modes)

Total estimated CI delta: under 2 minutes. Within budget.


TDD order: write the failing @DataJpaTest and @WebMvcTest tests 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.

## 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 by `type=MENTION` + `read=false` returns 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; `status` filter on search delegates the correct predicate to the repository; `size` cap on incomplete list is respected at the service layer **Frontend (Vitest + @testing-library/svelte)** - `DashboardMentions.svelte` — renders list when unread mentions present; hides entire widget (no DOM node, not just `display: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 `/enrich` - `DashboardRecentDocuments.svelte` — renders title, formatted date, sender name; links to document detail page; capped at 5 items --- ### Layer 3 — Integration Tests **Backend (`@WebMvcTest` slices)** - `NotificationControllerTest` - `GET /api/notifications?type=MENTION&read=false` → 200, returns only unread mentions - `GET /api/notifications?type=MENTION&read=true` → 200, returns only read mentions - `GET /api/notifications?read=false` (no type) → 200, returns all unread regardless of type - Unknown `type` value → 400 - Unauthenticated request → 401 - `DocumentControllerTest` (new endpoint) - `GET /api/documents/incomplete` → 200, list of `{id, title}`, max 3 - `GET /api/documents/incomplete` when zero incomplete → 200, empty list (widget hides in frontend — backend returns empty, not 404) - Unauthenticated → 401 - `DocumentControllerTest` (status filter on search) - `GET /api/documents/search?status=ARCHIVED` → 200, only archived documents - `GET /api/documents/search?status=REVIEWED` → 200 - `GET /api/documents/search` (no status param) → 200, returns all (no regression) - Invalid status value → 400 **Backend (`@DataJpaTest` + Testcontainers — real PostgreSQL 16)** - Repository-level query for incomplete documents: fixtures with mixed complete/incomplete rows; verify count and content match - Notification repository: fixtures with mixed type/read combinations; verify all four filter permutations return the correct subset **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 is `null`, not thrown) --- ### Layer 4 — E2E Tests (Playwright) Critical journeys only — permutations belong at the integration layer. **Dashboard mode (no active filters)** ``` test('dashboard renders all three widgets when no filters active') test('clicking a mention item navigates to document with deep-link params') test('clicking a needs-metadata item navigates to /documents/[id]/edit') test('clicking a recently-added item navigates to document detail') test('dashboard widgets disappear when user types in search bar') ``` **Search mode (filter active)** ``` test('document list renders and widgets are absent when search query present') test('clearing search query restores dashboard widgets') ``` **Responsive layout** ``` test('at 320px widgets are stacked single-column') test('at 768px top two widgets are side-by-side (grid-cols-2)') ``` **Accessibility (axe-playwright — runs on every page visit, non-negotiable)** ```typescript await checkA11y(page, undefined, { detailedReport: true, detailedReportOptions: { html: true } }); ``` Run after dashboard renders and again after entering search mode. Zero violations is a hard quality gate. **Additional a11y assertions (beyond axe)** - Each widget card is a `<section>` with `aria-labelledby` wired to the heading — verify via DOM assertion - All list item links have a touch target ≥ 44×44px at 320px viewport — verify via `boundingBox()` - Dark mode: visual regression snapshot at 320px, 768px, 1440px × light/dark — Leonie reviews diffs before merge --- ### Testability Concerns / Risks 1. **Parallel fetch in `+page.server.ts`** — if this is implemented with `Promise.all`, a single failing API call will reject everything. The load function should use `Promise.allSettled` and return `null` for failed widgets. Write the integration test for this failure mode *before* the implementation so it drives the right design. 2. **`hide if zero` rule** — "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 (check `queryByRole('region')` returns `null`, not just that nothing is visible). 3. **`status` filter on search** — verify the existing search tests still pass without modification (no regression on callers that don't pass a status param). 4. **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 | Layer | Delta | |---|---| | Unit | +~12 test cases, negligible time | | Integration (backend) | +~14 test cases, ~20-30s (Testcontainers already warm) | | Integration (frontend load) | +~4 test cases, negligible | | E2E | +~8 test cases, estimated +60-90s | | Visual regression | +6 snapshots (3 breakpoints × 2 modes) | Total estimated CI delta: **under 2 minutes**. Within budget. --- TDD order: write the failing `@DataJpaTest` and `@WebMvcTest` tests 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.
Sign in to join this conversation.
No Label feature ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#145