As a user I want to see uploaded documents needing metadata on the dashboard so I can continue enriching after a batch upload #296

Closed
opened 2026-04-20 20:02:27 +02:00 by marcel · 10 comments
Owner

Problem

With the new dashboard, the enrichment queue widget disappeared. After a user (typically a senior) batch-uploads 10–15 documents via the DropZone, there is no visible next step on the dashboard — the page refreshes, the files are there, but the user has no obvious path to add metadata (sender, date, tags) so the documents become searchable.

The DashboardNeedsMetadata.svelte component still exists in frontend/src/lib/components/ but is no longer imported into +page.svelte.

Proposed solution

A dedicated list-block placed on the dashboard between the Resume strip and the MissionControlStrip, showing up to 5 of the most-recently-uploaded docs that still need metadata, with a footer link to /enrich when more than 5 are pending.

Not a 4th column in MissionControlStrip, because:

  • The strip is already at 3-column visual capacity
  • Batch-upload reality (10–15 docs) makes a count tile useless — seniors need to see which docs are waiting
  • Placing it directly under the DropZone sightline makes upload → enrich the most visually-coupled pair on the page

Spec

Full design spec with scaled mockups (320px + 1440px), row anatomy, responsive breakpoints, state matrix (empty / loading / error / after-upload), a11y contract, dark-mode notes, and an impl-ref table of exact Tailwind classes + pixel values:

➡️ docs/specs/enrichment-list-block-spec.html

Key design decisions

  • Row cap = 5 on the dashboard; /enrich stays the canonical "long queue" page
  • Empty state renders nothing — no "all clear" card on a clean dashboard
  • No auto-redirect after upload — use a transient success banner with "Jetzt ergänzen →" CTA instead (dumping a senior who just dropped 12 PDFs into one edit form is disorienting)
  • Row = single <a>, 72px mobile / 64px desktop (well above WCAG 2.2 AA 48px floor)
  • File-type badge uses color + text ("PDF", "JPG") — passes WCAG 1.4.1 (not color alone)

Backend dependency

Extend IncompleteDocumentDTO (currently only id + title):

public record IncompleteDocumentDTO(
    UUID id,
    String title,
    Instant uploadedAt,   // NEW — drives "vor 2 Min." relative time
    String mimeType       // NEW — drives file-type badge colour
) {}

Small, cheap change. Without them the block still works, just without the relative-time meta line and the file-type badge.

Acceptance criteria

  • DashboardNeedsMetadata.svelte is re-wired into +page.svelte between Resume strip and MissionControlStrip
  • Row anatomy matches spec section 02 (icon · title · upload time · chevron; entire row is the <a>)
  • 5-item cap with footer link "Alle {n} anzeigen →" when length > 5
  • Empty state: component returns null
  • After successful upload in DropZone: transient banner appears above the block with "X Dokumente hochgeladen · Jetzt ergänzen →" CTA, auto-dismissing after 8s, with a manual close button
  • Backend: IncompleteDocumentDTO extended with uploadedAt and mimeType; TypeScript types regenerated
  • Vitest: renders nothing at length 0, no footer at 1–5, footer at 6+
  • Playwright + axe in both light and dark mode on the dashboard — no violations
  • Playwright screenshots at 320 / 768 / 1440 — no overflow, title truncates cleanly
  • Playwright: keyboard Tab reaches banner → rows → footer link; Enter navigates to /enrich/{id}
  • i18n: new Paraglide keys added for de / en / es (relative time, banner copy, footer link)

Out of scope (separate issues later)

  • List-block treatment for the other pipeline stages (segment / transcribe / review)
  • Bulk-enrich flows ("same sender for all", "apply N tags to all")
  • Sort/filter on /enrich
  • Thumbnails in rows
  • Strip redesign for a future 4th+ pipeline stage
## Problem With the new dashboard, the enrichment queue widget disappeared. After a user (typically a senior) batch-uploads 10–15 documents via the DropZone, there is **no visible next step** on the dashboard — the page refreshes, the files are there, but the user has no obvious path to add metadata (sender, date, tags) so the documents become searchable. The `DashboardNeedsMetadata.svelte` component still exists in `frontend/src/lib/components/` but is no longer imported into `+page.svelte`. ## Proposed solution A dedicated list-block placed on the dashboard **between the Resume strip and the MissionControlStrip**, showing up to 5 of the most-recently-uploaded docs that still need metadata, with a footer link to `/enrich` when more than 5 are pending. **Not a 4th column in MissionControlStrip**, because: - The strip is already at 3-column visual capacity - Batch-upload reality (10–15 docs) makes a count tile useless — seniors need to see *which* docs are waiting - Placing it directly under the DropZone sightline makes upload → enrich the most visually-coupled pair on the page ## Spec Full design spec with scaled mockups (320px + 1440px), row anatomy, responsive breakpoints, state matrix (empty / loading / error / after-upload), a11y contract, dark-mode notes, and an `impl-ref` table of exact Tailwind classes + pixel values: ➡️ [`docs/specs/enrichment-list-block-spec.html`](../src/branch/main/docs/specs/enrichment-list-block-spec.html) ## Key design decisions - **Row cap = 5** on the dashboard; `/enrich` stays the canonical "long queue" page - **Empty state renders nothing** — no "all clear" card on a clean dashboard - **No auto-redirect after upload** — use a transient success banner with "Jetzt ergänzen →" CTA instead (dumping a senior who just dropped 12 PDFs into one edit form is disorienting) - **Row = single `<a>`**, 72px mobile / 64px desktop (well above WCAG 2.2 AA 48px floor) - **File-type badge** uses color + text ("PDF", "JPG") — passes WCAG 1.4.1 (not color alone) ## Backend dependency Extend `IncompleteDocumentDTO` (currently only `id` + `title`): ```java public record IncompleteDocumentDTO( UUID id, String title, Instant uploadedAt, // NEW — drives "vor 2 Min." relative time String mimeType // NEW — drives file-type badge colour ) {} ``` Small, cheap change. Without them the block still works, just without the relative-time meta line and the file-type badge. ## Acceptance criteria - [ ] `DashboardNeedsMetadata.svelte` is re-wired into `+page.svelte` between Resume strip and MissionControlStrip - [ ] Row anatomy matches spec section 02 (icon · title · upload time · chevron; entire row is the `<a>`) - [ ] 5-item cap with footer link "Alle {n} anzeigen →" when `length > 5` - [ ] Empty state: component returns `null` - [ ] After successful upload in DropZone: transient banner appears above the block with "X Dokumente hochgeladen · Jetzt ergänzen →" CTA, auto-dismissing after 8s, with a manual close button - [ ] Backend: `IncompleteDocumentDTO` extended with `uploadedAt` and `mimeType`; TypeScript types regenerated - [ ] Vitest: renders nothing at length 0, no footer at 1–5, footer at 6+ - [ ] Playwright + axe in **both light and dark mode** on the dashboard — no violations - [ ] Playwright screenshots at 320 / 768 / 1440 — no overflow, title truncates cleanly - [ ] Playwright: keyboard Tab reaches banner → rows → footer link; Enter navigates to `/enrich/{id}` - [ ] i18n: new Paraglide keys added for `de` / `en` / `es` (relative time, banner copy, footer link) ## Out of scope (separate issues later) - List-block treatment for the other pipeline stages (segment / transcribe / review) - Bulk-enrich flows ("same sender for all", "apply N tags to all") - Sort/filter on `/enrich` - Thumbnails in rows - Strip redesign for a future 4th+ pipeline stage
marcel added the featurefile-uploadui labels 2026-04-20 20:02:33 +02:00
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Observations

  • Backend endpoint listed in the issue doesn't exist. The spec and AC talk about wiring DashboardNeedsMetadata.svelte via an incomplete-docs API, but the controller only exposes @GetMapping("/incomplete-count") (line 195) and @GetMapping("/incomplete/next") (line 200). There is no @GetMapping("/incomplete") returning the list. Consequently, /enrich/+page.server.ts:18 calls api.GET('/api/documents/incomplete') against a 404 — the /enrich page has been silently empty for some time. Generated types confirm: frontend/src/lib/generated/api.ts contains only /api/documents/incomplete/next and /api/documents/incomplete-count, no list path.
  • DocumentService.findIncompleteDocuments(int size) (service line 541) already exists and returns List<IncompleteDocumentDTO> sorted by createdAt DESC. The wiring gap is entirely in the controller.
  • The dashboard loader (+page.server.ts) uses Promise.allSettled over 8 endpoint calls; adding a 9th for the incomplete list is trivial and matches the existing pattern.
  • findIncompleteDocuments filters on metadataComplete=false, not on DocumentStatus. Fine — it's more flexible — but the AC should name this so future contributors understand "incomplete" is a boolean flag on the entity, not a status transition.

Recommendations

  • Add the missing endpoint first, as a standalone prerequisite commit. It shouldn't be bundled with the dashboard work — it's a prerequisite and it also fixes /enrich.
    @GetMapping("/incomplete")
    public List<IncompleteDocumentDTO> getIncompleteDocuments(
            @RequestParam(defaultValue = "50") int size) {
        return documentService.findIncompleteDocuments(Math.min(size, 200));
    }
    
    Cap the size parameter server-side (Math.min(size, 200)) — never trust a client-supplied limit unbounded.
  • DTO extension goes in the same PR as the endpoint, not as an afterthought. The impl-ref table in the spec depends on uploadedAt and mimeType being present. Doing them separately means the frontend lands with undefined fields and the meta line never renders.
  • Dashboard loader addition: one more entry in the Promise.allSettled array. No new module boundaries. Stick to the existing pattern — don't invent a dashboard-aggregator endpoint just to bundle calls. Parallel fetches are already fast.
  • Keep the component and block-wrapper separate: DashboardNeedsMetadata.svelte renders the list; if the AC grows (banner, skeleton, empty-state handling), put those in a parent EnrichmentBlock.svelte rather than bloating the existing component. One nameable visual region = one component.

Open Decisions

  • Endpoint path naming consistency. The existing endpoints are /incomplete-count and /incomplete/next. Adding /incomplete as the list root is conventional REST, but it creates a slightly awkward set: GET /incomplete (list), GET /incomplete/next (singular random), GET /incomplete-count (count). A cleaner shape would be GET /incomplete, GET /incomplete?count=true or GET /incomplete/count and GET /incomplete/next (all nested). Refactoring the existing paths is a breaking change for /enrich/[id]/+page.server.ts — acceptable one-time cost, or leave as-is? My preference: leave as-is and add /incomplete list; the inconsistency is minor and a refactor for tidiness isn't worth the risk on working code.
## 🏛️ Markus Keller — Senior Application Architect ### Observations - **Backend endpoint listed in the issue doesn't exist.** The spec and AC talk about wiring `DashboardNeedsMetadata.svelte` via an incomplete-docs API, but the controller only exposes `@GetMapping("/incomplete-count")` (line 195) and `@GetMapping("/incomplete/next")` (line 200). There is **no** `@GetMapping("/incomplete")` returning the list. Consequently, `/enrich/+page.server.ts:18` calls `api.GET('/api/documents/incomplete')` against a 404 — the `/enrich` page has been silently empty for some time. Generated types confirm: `frontend/src/lib/generated/api.ts` contains only `/api/documents/incomplete/next` and `/api/documents/incomplete-count`, no list path. - `DocumentService.findIncompleteDocuments(int size)` (service line 541) already exists and returns `List<IncompleteDocumentDTO>` sorted by `createdAt DESC`. The wiring gap is entirely in the controller. - The dashboard loader (`+page.server.ts`) uses `Promise.allSettled` over 8 endpoint calls; adding a 9th for the incomplete list is trivial and matches the existing pattern. - `findIncompleteDocuments` filters on `metadataComplete=false`, not on `DocumentStatus`. Fine — it's more flexible — but the AC should name this so future contributors understand "incomplete" is a boolean flag on the entity, not a status transition. ### Recommendations - **Add the missing endpoint first, as a standalone prerequisite commit.** It shouldn't be bundled with the dashboard work — it's a prerequisite and it also fixes `/enrich`. ```java @GetMapping("/incomplete") public List<IncompleteDocumentDTO> getIncompleteDocuments( @RequestParam(defaultValue = "50") int size) { return documentService.findIncompleteDocuments(Math.min(size, 200)); } ``` Cap the size parameter server-side (`Math.min(size, 200)`) — never trust a client-supplied limit unbounded. - **DTO extension goes in the same PR as the endpoint**, not as an afterthought. The impl-ref table in the spec depends on `uploadedAt` and `mimeType` being present. Doing them separately means the frontend lands with `undefined` fields and the meta line never renders. - **Dashboard loader addition**: one more entry in the `Promise.allSettled` array. No new module boundaries. Stick to the existing pattern — don't invent a dashboard-aggregator endpoint just to bundle calls. Parallel fetches are already fast. - **Keep the component and block-wrapper separate**: `DashboardNeedsMetadata.svelte` renders the list; if the AC grows (banner, skeleton, empty-state handling), put those in a parent `EnrichmentBlock.svelte` rather than bloating the existing component. One nameable visual region = one component. ### Open Decisions - **Endpoint path naming consistency.** The existing endpoints are `/incomplete-count` and `/incomplete/next`. Adding `/incomplete` as the list root is conventional REST, but it creates a slightly awkward set: `GET /incomplete` (list), `GET /incomplete/next` (singular random), `GET /incomplete-count` (count). A cleaner shape would be `GET /incomplete`, `GET /incomplete?count=true` *or* `GET /incomplete/count` and `GET /incomplete/next` (all nested). Refactoring the existing paths is a breaking change for `/enrich/[id]/+page.server.ts` — acceptable one-time cost, or leave as-is? My preference: leave as-is and add `/incomplete` list; the inconsistency is minor and a refactor for tidiness isn't worth the risk on working code.
Author
Owner

🔧 Tobias Wendt — DevOps & Platform Engineer

Observations

  • No infrastructure changes. No new services, no Compose edits, no secrets, no ports, no volumes. This is purely application-layer work.
  • CI impact: the AC adds Playwright + axe in both themes across three breakpoints (320/768/1440). That's 6 new browser runs on the dashboard route. Current E2E target per Sara's pyramid is <8 minutes — this would be <20 seconds added if written as parameterized tests, which is fine.
  • No Flyway migration needed for the DTO extension. Document.createdAt and the file's stored mime type already exist on the entity; the new fields just need to be populated from existing columns in the mapper at line 545 of DocumentService.java.

Recommendations

  • Observability nudge (low priority, not blocking): once this ships, add a Grafana panel tracking median minutes from upload to metadataComplete=true. That's a direct signal for whether the banner+list-block actually closes the loop for seniors. Single SQL query, one panel, no new infrastructure:
    SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY
      EXTRACT(EPOCH FROM (updated_at - created_at)) / 60) AS median_minutes
    FROM documents WHERE metadata_complete = true
      AND created_at > now() - interval '7 days';
    
    This doesn't have to be in this PR — file as a follow-up.
  • Gitea Actions runtime: the visual regression screenshots at 3 widths × 2 themes = 6 artifacts per run. actions/upload-artifact@v4 caps fine, but make sure the artifact retention policy isn't set to forever — spec HTML + PNGs add up fast across iterations. Default 30 days is right.

No concerns from my angle on the feature itself. The work stays inside the application stack. I'd only get involved if the backend endpoint addition needed new scaling headroom or the Playwright additions pushed CI over the 8-minute SLO.

## 🔧 Tobias Wendt — DevOps & Platform Engineer ### Observations - No infrastructure changes. No new services, no Compose edits, no secrets, no ports, no volumes. This is purely application-layer work. - CI impact: the AC adds Playwright + axe in **both themes** across **three breakpoints** (320/768/1440). That's 6 new browser runs on the dashboard route. Current E2E target per Sara's pyramid is <8 minutes — this would be <20 seconds added if written as parameterized tests, which is fine. - No Flyway migration needed for the DTO extension. `Document.createdAt` and the file's stored mime type already exist on the entity; the new fields just need to be populated from existing columns in the mapper at line 545 of `DocumentService.java`. ### Recommendations - **Observability nudge (low priority, not blocking):** once this ships, add a Grafana panel tracking *median minutes from upload to `metadataComplete=true`*. That's a direct signal for whether the banner+list-block actually closes the loop for seniors. Single SQL query, one panel, no new infrastructure: ```sql SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (updated_at - created_at)) / 60) AS median_minutes FROM documents WHERE metadata_complete = true AND created_at > now() - interval '7 days'; ``` This doesn't have to be in this PR — file as a follow-up. - **Gitea Actions runtime**: the visual regression screenshots at 3 widths × 2 themes = 6 artifacts per run. `actions/upload-artifact@v4` caps fine, but make sure the artifact retention policy isn't set to forever — spec HTML + PNGs add up fast across iterations. Default 30 days is right. _No concerns from my angle on the feature itself._ The work stays inside the application stack. I'd only get involved if the backend endpoint addition needed new scaling headroom or the Playwright additions pushed CI over the 8-minute SLO.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • Markus and I found the same thing: the frontend references GET /api/documents/incomplete but the endpoint doesn't exist. /enrich/+page.server.ts:18 is a silent 404. My TDD cycle starts there.
  • DropZone wiring is non-trivial. Current flow: DropZone.svelte holds uploadMessages as local $state and renders them inline inside its own sidebar container (line 181–198). The spec's post-upload banner lives above the list block in the main content column — that means DropZone cannot own the banner state. State has to be lifted to +page.svelte, or DropZone emits an event the page listens to.
  • comment_time_minutes: "vor {count} Minute(n)" (de.json:351), _hours and _days already exist with Paraglide parameter support. Reuse, don't duplicate.
  • IncompleteDocumentDTO is a record — extending it is one line plus the mapper on DocumentService.java:545. mimeType comes from the stored file entry, uploadedAt is doc.getCreatedAt().
  • Tests already cover DashboardNeedsMetadata.svelte (DashboardNeedsMetadata.svelte.spec.ts) — I'll extend rather than replace.

Recommendations

Ordered TDD cycle (one commit per red/green):

  1. Backend: red — failing DocumentControllerTest.should_return_incomplete_documents_sorted_by_most_recent(). Then green — add @GetMapping("/incomplete") with size cap. Mirror the @RequirePermission decision from Nora's comment.
  2. Backend: red — failing test asserting the DTO contains uploadedAt and mimeType. Then green — extend the record and the mapper.
  3. Backend: regen types: mvnw spring-boot:run --spring.profiles.active=dev + npm run generate:api in frontend.
  4. Frontend: red — component test for the 5-row cap and footer link. Then green — update DashboardNeedsMetadata.svelte to match the spec's row anatomy.
  5. Frontend: red — loader test for the new Promise.allSettled entry. Then green — add the fetch.
  6. Frontend: red — banner tests (appears on upload completion, manual dismiss, auto-dismiss at 8s). Then green — implement.
  7. Refactor under green: split EnrichmentBlock.svelte parent from DashboardNeedsMetadata.svelte per Markus.

Banner state lifting — clear winner: callback prop from DropZone.

<!-- DropZone.svelte -->
<script lang="ts">
  interface Props {
    onUploadComplete?: (count: number) => void;
  }
  let { onUploadComplete }: Props = $props();
  // inside uploadFiles, after invalidateAll():
  if (result.created?.length > 0) onUploadComplete?.(result.created.length);
</script>

<!-- +page.svelte -->
<DropZone onUploadComplete={(n) => (bannerCount = n)} />
{#if bannerCount > 0}<UploadSuccessBanner count={bannerCount} onClose={...} />{/if}

No Svelte stores needed. Lifts state to the orchestrator, keeps DropZone focused on upload mechanics.

Props naming for the list block: pass two props, not one.

interface Props {
  topDocs: IncompleteDocumentDTO[];  // max 5, already capped by caller
  totalCount: number;                 // drives badge + footer-link visibility
}

The current incompleteDocs prop conflates "list to render" with "total count" — the spec needs both, and topDocs.lengthtotalCount when more than 5 are pending.

Relative-time helper: extract once, don't duplicate.

// $lib/relativeTime.ts
export function relativeTimeDe(from: Date, now: Date = new Date()): string {
  const mins = Math.round((now.getTime() - from.getTime()) / 60000);
  if (mins < 60) return m.comment_time_minutes({ count: mins });
  if (mins < 1440) return m.comment_time_hours({ count: Math.round(mins / 60) });
  return m.comment_time_days({ count: Math.round(mins / 1440) });
}

Pure function, trivially unit-testable with an injected now — no clock flake.

Open Decisions

None — all the tradeoffs above have a clear winner.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - Markus and I found the same thing: the frontend references `GET /api/documents/incomplete` but the endpoint doesn't exist. `/enrich/+page.server.ts:18` is a silent 404. My TDD cycle starts there. - DropZone wiring is non-trivial. Current flow: `DropZone.svelte` holds `uploadMessages` as local `$state` and renders them inline *inside its own sidebar container* (line 181–198). The spec's post-upload banner lives **above** the list block in the main content column — that means DropZone cannot own the banner state. State has to be lifted to `+page.svelte`, or DropZone emits an event the page listens to. - `comment_time_minutes: "vor {count} Minute(n)"` (de.json:351), `_hours` and `_days` already exist with Paraglide parameter support. Reuse, don't duplicate. - `IncompleteDocumentDTO` is a record — extending it is one line plus the mapper on `DocumentService.java:545`. `mimeType` comes from the stored file entry, `uploadedAt` is `doc.getCreatedAt()`. - Tests already cover `DashboardNeedsMetadata.svelte` (`DashboardNeedsMetadata.svelte.spec.ts`) — I'll extend rather than replace. ### Recommendations **Ordered TDD cycle (one commit per red/green):** 1. **Backend: red** — failing `DocumentControllerTest.should_return_incomplete_documents_sorted_by_most_recent()`. Then **green** — add `@GetMapping("/incomplete")` with size cap. Mirror the `@RequirePermission` decision from Nora's comment. 2. **Backend: red** — failing test asserting the DTO contains `uploadedAt` and `mimeType`. Then **green** — extend the record and the mapper. 3. **Backend: regen types**: `mvnw spring-boot:run --spring.profiles.active=dev` + `npm run generate:api` in frontend. 4. **Frontend: red** — component test for the 5-row cap and footer link. Then **green** — update `DashboardNeedsMetadata.svelte` to match the spec's row anatomy. 5. **Frontend: red** — loader test for the new `Promise.allSettled` entry. Then **green** — add the fetch. 6. **Frontend: red** — banner tests (appears on upload completion, manual dismiss, auto-dismiss at 8s). Then **green** — implement. 7. **Refactor under green**: split `EnrichmentBlock.svelte` parent from `DashboardNeedsMetadata.svelte` per Markus. **Banner state lifting — clear winner:** callback prop from DropZone. ```svelte <!-- DropZone.svelte --> <script lang="ts"> interface Props { onUploadComplete?: (count: number) => void; } let { onUploadComplete }: Props = $props(); // inside uploadFiles, after invalidateAll(): if (result.created?.length > 0) onUploadComplete?.(result.created.length); </script> <!-- +page.svelte --> <DropZone onUploadComplete={(n) => (bannerCount = n)} /> {#if bannerCount > 0}<UploadSuccessBanner count={bannerCount} onClose={...} />{/if} ``` No Svelte stores needed. Lifts state to the orchestrator, keeps DropZone focused on upload mechanics. **Props naming for the list block:** pass two props, not one. ```svelte interface Props { topDocs: IncompleteDocumentDTO[]; // max 5, already capped by caller totalCount: number; // drives badge + footer-link visibility } ``` The current `incompleteDocs` prop conflates "list to render" with "total count" — the spec needs both, and `topDocs.length` ≠ `totalCount` when more than 5 are pending. **Relative-time helper:** extract once, don't duplicate. ```typescript // $lib/relativeTime.ts export function relativeTimeDe(from: Date, now: Date = new Date()): string { const mins = Math.round((now.getTime() - from.getTime()) / 60000); if (mins < 60) return m.comment_time_minutes({ count: mins }); if (mins < 1440) return m.comment_time_hours({ count: Math.round(mins / 60) }); return m.comment_time_days({ count: Math.round(mins / 1440) }); } ``` Pure function, trivially unit-testable with an injected `now` — no clock flake. ### Open Decisions _None — all the tradeoffs above have a clear winner._
Author
Owner

🔐 Nora "NullX" Steiner — Application Security Engineer

Observations

  • Existing authorization gap, now inherited by the new endpoint. DocumentController.java:195 (/incomplete-count) and :200 (/incomplete/next) have no @RequirePermission annotation. All other write endpoints in this controller are decorated (:103, :115, :131, :143, :161, :234), so the inconsistency is a pre-existing oversight, not a deliberate public contract.
  • Impact: any authenticated user — including one with only READ_ALL — can enumerate incomplete document titles and IDs via /incomplete-count (count only, minor) and /incomplete/next (full Document entity including sender, tags, file path — more sensitive). CWE-285 (Improper Authorization).
  • The new /incomplete list endpoint will inherit this gap unless explicitly annotated. The AC doesn't mention permissions.
  • Frontend enrich/+page.server.ts:11-15 already implements the correct policy in the UI layer: redirects non-WRITE_ALL users to /. The server-side permission annotation should mirror that intent — defense in depth.

Recommendations

  1. Gate all three /incomplete* endpoints with @RequirePermission(Permission.WRITE_ALL) — including the two existing ones. Enrichment is a write action; users who cannot perform it shouldn't see the queue. Do this in the same PR as the new endpoint so the consistency story is clean.

    @GetMapping("/incomplete-count")
    @RequirePermission(Permission.WRITE_ALL)
    public Map<String, Long> getIncompleteCount() { ... }
    
    @GetMapping("/incomplete")
    @RequirePermission(Permission.WRITE_ALL)
    public List<IncompleteDocumentDTO> getIncompleteDocuments(...) { ... }
    
    @GetMapping("/incomplete/next")
    @RequirePermission(Permission.WRITE_ALL)
    public ResponseEntity<Document> getNextIncomplete(...) { ... }
    
  2. Regression test (mandatory, per my standing rule with Sara):

    @Test
    void incomplete_returns403_when_user_has_READ_ALL_only() {
        mockMvc.perform(get("/api/documents/incomplete")
            .with(user("reader").authorities(new SimpleGrantedAuthority("READ_ALL"))))
            .andExpect(status().isForbidden());
    }
    

    Plus the 401 (unauthenticated) variant. Both existing endpoints get the same tests retroactively — their lack of annotation was already an un-tested authorization bug.

  3. mimeType handling in the frontend: do not trust arbitrary mime-type strings for CSS-class routing. The spec's file-type badge uses mime → color mapping (red/PDF, green/JPG, purple/TIF). Build that mapping as a hard allowlist and fall back to a generic "DOC" badge for anything unknown:

    const MIME_BADGES: Record<string, { label: string; class: string }> = {
      'application/pdf': { label: 'PDF', class: 'bg-red-100 text-red-700' },
      'image/jpeg':      { label: 'JPG', class: 'bg-green-100 text-green-700' },
      'image/png':       { label: 'PNG', class: 'bg-green-100 text-green-700' },
      'image/tiff':      { label: 'TIF', class: 'bg-purple-100 text-purple-700' },
    };
    const badge = MIME_BADGES[doc.mimeType] ?? { label: 'DOC', class: 'bg-gray-100 text-gray-700' };
    

    Why: if an upload path (or a future one) ever accepted a weirder mime, we're not interpolating arbitrary strings into a class attribute. Defensive, cheap, closes the XSS-via-CSS-class door even if it's mostly theoretical given current upload validation.

  4. Banner a11y → security crossover: the manual close button must be keyboard-reachable with a descriptive aria-label ("Benachrichtigung schließen"). Invisible or unreachable dismiss controls are an accessibility AND usability-attack surface: a user who cannot dismiss the banner cannot see what's below it. The spec already calls this out — just verify it ships.

Open Decisions

  • Permission level for the list endpoint. WRITE_ALL is my recommendation (matches intent: only writers need to see the queue). An alternative is READ_ALL with a rationale that enrichment is partially a discoverability feature ("I want to know what's pending so I can ping someone who has write access"). That's a real product question I can't decide alone — it depends on whether your family has distinct "reader" roles who need visibility into incomplete work without the ability to complete it.
## 🔐 Nora "NullX" Steiner — Application Security Engineer ### Observations - **Existing authorization gap, now inherited by the new endpoint.** `DocumentController.java:195` (`/incomplete-count`) and `:200` (`/incomplete/next`) have **no** `@RequirePermission` annotation. All other write endpoints in this controller are decorated (`:103`, `:115`, `:131`, `:143`, `:161`, `:234`), so the inconsistency is a pre-existing oversight, not a deliberate public contract. - Impact: any authenticated user — including one with only `READ_ALL` — can enumerate incomplete document titles and IDs via `/incomplete-count` (count only, minor) and `/incomplete/next` (full `Document` entity including sender, tags, file path — more sensitive). CWE-285 (Improper Authorization). - The new `/incomplete` list endpoint will inherit this gap unless explicitly annotated. The AC doesn't mention permissions. - Frontend `enrich/+page.server.ts:11-15` already implements the correct policy in the UI layer: redirects non-`WRITE_ALL` users to `/`. The server-side permission annotation should mirror that intent — defense in depth. ### Recommendations 1. **Gate all three `/incomplete*` endpoints with `@RequirePermission(Permission.WRITE_ALL)`** — including the two existing ones. Enrichment is a write action; users who cannot perform it shouldn't see the queue. Do this in the same PR as the new endpoint so the consistency story is clean. ```java @GetMapping("/incomplete-count") @RequirePermission(Permission.WRITE_ALL) public Map<String, Long> getIncompleteCount() { ... } @GetMapping("/incomplete") @RequirePermission(Permission.WRITE_ALL) public List<IncompleteDocumentDTO> getIncompleteDocuments(...) { ... } @GetMapping("/incomplete/next") @RequirePermission(Permission.WRITE_ALL) public ResponseEntity<Document> getNextIncomplete(...) { ... } ``` 2. **Regression test** (mandatory, per my standing rule with Sara): ```java @Test void incomplete_returns403_when_user_has_READ_ALL_only() { mockMvc.perform(get("/api/documents/incomplete") .with(user("reader").authorities(new SimpleGrantedAuthority("READ_ALL")))) .andExpect(status().isForbidden()); } ``` Plus the 401 (unauthenticated) variant. Both existing endpoints get the same tests retroactively — their lack of annotation was already an un-tested authorization bug. 3. **`mimeType` handling in the frontend:** do not trust arbitrary mime-type strings for CSS-class routing. The spec's file-type badge uses `mime → color` mapping (red/PDF, green/JPG, purple/TIF). Build that mapping as a hard allowlist and fall back to a generic "DOC" badge for anything unknown: ```typescript const MIME_BADGES: Record<string, { label: string; class: string }> = { 'application/pdf': { label: 'PDF', class: 'bg-red-100 text-red-700' }, 'image/jpeg': { label: 'JPG', class: 'bg-green-100 text-green-700' }, 'image/png': { label: 'PNG', class: 'bg-green-100 text-green-700' }, 'image/tiff': { label: 'TIF', class: 'bg-purple-100 text-purple-700' }, }; const badge = MIME_BADGES[doc.mimeType] ?? { label: 'DOC', class: 'bg-gray-100 text-gray-700' }; ``` Why: if an upload path (or a future one) ever accepted a weirder mime, we're not interpolating arbitrary strings into a class attribute. Defensive, cheap, closes the XSS-via-CSS-class door even if it's mostly theoretical given current upload validation. 4. **Banner a11y → security crossover:** the manual close button must be keyboard-reachable with a descriptive `aria-label` (`"Benachrichtigung schließen"`). Invisible or unreachable dismiss controls are an accessibility AND usability-attack surface: a user who cannot dismiss the banner cannot see what's below it. The spec already calls this out — just verify it ships. ### Open Decisions - **Permission level for the list endpoint.** `WRITE_ALL` is my recommendation (matches intent: only writers need to see the queue). An alternative is `READ_ALL` with a rationale that enrichment is partially a discoverability feature ("I want to know what's pending so I can ping someone who has write access"). That's a real product question I can't decide alone — it depends on whether your family has distinct "reader" roles who need visibility into incomplete work without the ability to complete it.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Observations

  • The AC has 11 checkboxes — better than most. But it mixes behaviors to test with implementation tasks. Let me re-slice it by test-pyramid layer so the coverage gaps are visible.
  • Time-sensitive code is a flake magnet. Relative-time strings ("vor 2 Min.") and the banner's 8-second auto-dismiss will both be flaky under default Playwright timings unless explicitly stubbed.
  • The existing DashboardNeedsMetadata.svelte.spec.ts test file should be updated, not replaced. I'll check it during review to make sure it isn't a snapshot test masquerading as coverage.
  • Dark-mode a11y is in the AC, but "no axe violations" is not the same as "file-type badge contrast passes in dark mode." axe catches what its rules catch; a mime-badge color on #0D1820 background needs the color-contrast rule explicitly enabled (it's default-on in wcag2aa, so fine — just making sure we don't suppress it for "decorative" badges).

Recommendations

Test plan by layer (target totals: <2 min integration + <1 min added E2E):

Backend — JUnit + Mockito (<5s)
├─ DocumentServiceTest.findIncompleteDocuments_sorts_by_created_at_desc
├─ DocumentServiceTest.findIncompleteDocuments_respects_size_limit
├─ DocumentServiceTest.findIncompleteDocuments_filters_metadata_complete_false
└─ IncompleteDocumentDTO includes uploadedAt and mimeType (record test)

Backend — @WebMvcTest (<15s)
├─ DocumentControllerTest.incomplete_returns_200_and_list_for_writer
├─ DocumentControllerTest.incomplete_returns_403_for_reader_only    ← Nora's test
├─ DocumentControllerTest.incomplete_returns_401_when_unauth         ← Nora's test
├─ DocumentControllerTest.incomplete_caps_size_at_200                ← security cap
└─ DocumentControllerTest.incomplete_count_returns_403_for_reader_only  ← retrofit
└─ DocumentControllerTest.incomplete_next_returns_403_for_reader_only   ← retrofit

Frontend — Vitest unit (<3s)
├─ relativeTimeDe returns minutes for <1h gap (with injected now)
├─ relativeTimeDe returns hours for 1–24h gap
├─ relativeTimeDe returns days for >24h gap
└─ MIME_BADGES.unknown returns fallback 'DOC' badge     ← Nora's allowlist

Frontend — vitest-browser-svelte (<5s)
├─ DashboardNeedsMetadata renders null when topDocs empty
├─ DashboardNeedsMetadata renders 1-5 rows without footer
├─ DashboardNeedsMetadata renders 5 rows + footer when totalCount > 5
├─ count badge shows totalCount (not topDocs.length) — boundary: 6, 10, 50
├─ UploadSuccessBanner auto-dismisses after 8s (with fake timers)
├─ UploadSuccessBanner dismisses on manual X click
└─ UploadSuccessBanner reads aria-live="polite" with count in text

Frontend — SvelteKit loader test (<3s)
└─ +page.server.ts includes incomplete in allSettled and gracefully handles 403

E2E — Playwright + axe (<45s added across matrix)
└─ Critical journey: login → upload 3 files → banner appears → click CTA → /enrich
    × viewports: 320, 768, 1440
    × themes: light, dark
    = 6 runs, parameterized

Concrete flake-prevention patterns:

  1. Freeze the clock in Vitest for relative-time tests:

    vi.setSystemTime(new Date('2026-04-20T12:00:00Z'));
    const doc = { uploadedAt: '2026-04-20T11:58:00Z', ... };
    expect(relativeTimeDe(new Date(doc.uploadedAt))).toContain('2 Minute');
    vi.useRealTimers();
    
  2. Banner 8s dismiss — use Vitest fake timers, not waitFor(8000):

    vi.useFakeTimers();
    render(UploadSuccessBanner, { props: { count: 3 } });
    vi.advanceTimersByTime(7999);
    await expect.element(getByRole('status')).toBeVisible();
    vi.advanceTimersByTime(2);
    await expect.element(getByRole('status')).not.toBeVisible();
    
  3. Playwright banner assertion — assert via role + text, don't rely on CSS class names that may change:

    await expect(page.getByRole('status')).toContainText(/3 Dokumente hochgeladen/);
    await page.getByRole('link', { name: /ergänzen/i }).click();
    await expect(page).toHaveURL(/\/enrich/);
    
  4. axe run in both themes already in AC — add explicit disableRules guard: make sure no one suppresses color-contrast on badges "because they're decorative."

Open Decisions

  • Viewport matrix. AC says 320/768/1440. Is 1920 needed? The dashboard grid is lg:grid-cols-[1fr_320px] (from +page.svelte:31), so 1440 is already well into the desktop layout. I'd skip 1920 unless Leonie says the visual weight shifts at wider widths. Ask Leonie.
  • Should the existing DashboardNeedsMetadata.svelte.spec.ts be extended or rewritten from scratch? If it's snapshot-heavy, delete and start over with behavioral tests. If it has meaningful assertions, extend. I can't tell without reading it — Felix, flag during your first red-phase.
## 🧪 Sara Holt — Senior QA Engineer ### Observations - The AC has 11 checkboxes — better than most. But it mixes *behaviors to test* with *implementation tasks*. Let me re-slice it by test-pyramid layer so the coverage gaps are visible. - **Time-sensitive code is a flake magnet.** Relative-time strings ("vor 2 Min.") and the banner's 8-second auto-dismiss will both be flaky under default Playwright timings unless explicitly stubbed. - The existing `DashboardNeedsMetadata.svelte.spec.ts` test file should be updated, not replaced. I'll check it during review to make sure it isn't a snapshot test masquerading as coverage. - Dark-mode a11y is in the AC, but "no axe violations" is not the same as "file-type badge contrast passes in dark mode." axe catches what its rules catch; a mime-badge color on `#0D1820` background needs the `color-contrast` rule explicitly enabled (it's default-on in `wcag2aa`, so fine — just making sure we don't suppress it for "decorative" badges). ### Recommendations **Test plan by layer (target totals: <2 min integration + <1 min added E2E):** ``` Backend — JUnit + Mockito (<5s) ├─ DocumentServiceTest.findIncompleteDocuments_sorts_by_created_at_desc ├─ DocumentServiceTest.findIncompleteDocuments_respects_size_limit ├─ DocumentServiceTest.findIncompleteDocuments_filters_metadata_complete_false └─ IncompleteDocumentDTO includes uploadedAt and mimeType (record test) Backend — @WebMvcTest (<15s) ├─ DocumentControllerTest.incomplete_returns_200_and_list_for_writer ├─ DocumentControllerTest.incomplete_returns_403_for_reader_only ← Nora's test ├─ DocumentControllerTest.incomplete_returns_401_when_unauth ← Nora's test ├─ DocumentControllerTest.incomplete_caps_size_at_200 ← security cap └─ DocumentControllerTest.incomplete_count_returns_403_for_reader_only ← retrofit └─ DocumentControllerTest.incomplete_next_returns_403_for_reader_only ← retrofit Frontend — Vitest unit (<3s) ├─ relativeTimeDe returns minutes for <1h gap (with injected now) ├─ relativeTimeDe returns hours for 1–24h gap ├─ relativeTimeDe returns days for >24h gap └─ MIME_BADGES.unknown returns fallback 'DOC' badge ← Nora's allowlist Frontend — vitest-browser-svelte (<5s) ├─ DashboardNeedsMetadata renders null when topDocs empty ├─ DashboardNeedsMetadata renders 1-5 rows without footer ├─ DashboardNeedsMetadata renders 5 rows + footer when totalCount > 5 ├─ count badge shows totalCount (not topDocs.length) — boundary: 6, 10, 50 ├─ UploadSuccessBanner auto-dismisses after 8s (with fake timers) ├─ UploadSuccessBanner dismisses on manual X click └─ UploadSuccessBanner reads aria-live="polite" with count in text Frontend — SvelteKit loader test (<3s) └─ +page.server.ts includes incomplete in allSettled and gracefully handles 403 E2E — Playwright + axe (<45s added across matrix) └─ Critical journey: login → upload 3 files → banner appears → click CTA → /enrich × viewports: 320, 768, 1440 × themes: light, dark = 6 runs, parameterized ``` **Concrete flake-prevention patterns:** 1. **Freeze the clock** in Vitest for relative-time tests: ```typescript vi.setSystemTime(new Date('2026-04-20T12:00:00Z')); const doc = { uploadedAt: '2026-04-20T11:58:00Z', ... }; expect(relativeTimeDe(new Date(doc.uploadedAt))).toContain('2 Minute'); vi.useRealTimers(); ``` 2. **Banner 8s dismiss** — use Vitest fake timers, not `waitFor(8000)`: ```typescript vi.useFakeTimers(); render(UploadSuccessBanner, { props: { count: 3 } }); vi.advanceTimersByTime(7999); await expect.element(getByRole('status')).toBeVisible(); vi.advanceTimersByTime(2); await expect.element(getByRole('status')).not.toBeVisible(); ``` 3. **Playwright banner assertion** — assert via role + text, don't rely on CSS class names that may change: ```typescript await expect(page.getByRole('status')).toContainText(/3 Dokumente hochgeladen/); await page.getByRole('link', { name: /ergänzen/i }).click(); await expect(page).toHaveURL(/\/enrich/); ``` 4. **axe run in both themes** already in AC — add explicit `disableRules` guard: make sure no one suppresses `color-contrast` on badges "because they're decorative." ### Open Decisions - **Viewport matrix.** AC says 320/768/1440. Is 1920 needed? The dashboard grid is `lg:grid-cols-[1fr_320px]` (from `+page.svelte:31`), so 1440 is already well into the desktop layout. I'd skip 1920 unless Leonie says the visual weight shifts at wider widths. Ask Leonie. - **Should the existing `DashboardNeedsMetadata.svelte.spec.ts` be extended or rewritten from scratch?** If it's snapshot-heavy, delete and start over with behavioral tests. If it has meaningful assertions, extend. I can't tell without reading it — Felix, flag during your first red-phase.
Author
Owner

🎨 Leonie Voss — UX Lead (self-review of the spec)

I authored the spec, so this is a close-reading for gaps I want Felix to catch during implementation rather than a design critique.

Observations

  • Dark-mode file-type badge contrast is under-specified. The spec section 07 flags it as a "verify" item but doesn't give exact dark-mode class values. That leaves room for Felix to ship text-red-600 bg-red-100 against a #0D1820 panel, which fails AA at ~3.1:1 for the text inside the badge. I should have given concrete dark-mode tokens in the impl-ref table.
  • The visual-hole problem between upload completion and block population. invalidateAll() in DropZone (line 97) triggers a full loader re-run. Between "banner appears" and "list block re-renders with fresh data" there's a perceptible gap on a slow connection — and if the user's queue was empty before the upload, the block was null and becomes populated. That's a layout shift of 400–600px on mobile exactly where the user is looking. Bad for seniors (disorienting).
  • Backend sort field. Spec says "most-recently-uploaded" but DocumentService.findIncompleteDocuments sorts by createdAt DESC. For fresh uploads these are identical. For docs created via Excel import (PLACEHOLDER lifecycle) they diverge. This only matters if a user wants "what I just dropped" to rank above "what got imported last month" — which they do. Flag for Felix.

Recommendations

  1. Pin dark-mode badge classes in the impl-ref table. Concrete values to drop into the spec's section 05 table row for "File-type badge":

    PDF:  light  → bg-red-100    text-red-700     (7.2:1 AAA)
          dark   → bg-red-950/60 text-red-300     (verify ≥ 4.5:1)
    JPG:  light  → bg-green-100  text-green-700   (8.1:1 AAA)
          dark   → bg-green-950/60 text-green-300 (verify ≥ 4.5:1)
    TIF:  light  → bg-purple-100 text-purple-700  (7.9:1 AAA)
          dark   → bg-purple-950/60 text-purple-300 (verify ≥ 4.5:1)
    

    I'll amend the spec separately. For this issue: Felix, use these, and let axe confirm — don't guess.

  2. Reserve block height during $navigating. If the block is null when empty, add a skeleton fallback while a fresh invalidation is in flight:

    {#if $navigating && !topDocs.length}
      <div class="h-[360px] bg-surface/50 rounded-sm animate-pulse motion-reduce:animate-none"
           aria-hidden="true"></div>
    {:else if topDocs.length > 0}
      <EnrichmentBlock {topDocs} {totalCount} />
    {/if}
    

    Keeps the scroll position stable while new docs flow in. motion-reduce:animate-none respects prefers-reduced-motion.

  3. Banner copy for 1 vs. N docs. Paraglide handles parameters but I didn't spec the singular/plural German grammar:

    count = 1: "1 Dokument hochgeladen. Jetzt ergänzen, damit es durchsuchbar wird."
    count > 1: "{count} Dokumente hochgeladen. Jetzt ergänzen, damit sie durchsuchbar werden."
    

    Two Paraglide keys: upload_banner_singular and upload_banner_plural. Don't let Felix write one key with ambiguous grammar.

  4. Row order on return-visits matters. If a user comes back after enriching 3 of 12 docs, the 9 remaining should still be the oldest-uploaded-first-incomplete (FIFO for the user's mental "I'm working through the shoebox"). Current sort is createdAt DESCnewest first — which is right for "fresh uploads at top" but wrong for "continuing yesterday's shoebox." This is a real friction point for senior batch-uploaders. Worth raising as a product question: list-block sort is always newest-first, or user-preference-sticky? My instinct: newest-first is correct because the banner-driven "just-uploaded" case is the dominant flow, and the /enrich page can offer sort controls later (out of scope per spec).

Open Decisions

  • Block-height reservation during loading. Adding the skeleton complicates the "renders null when empty" rule. Two options: (a) accept the layout shift — simple, occasionally jumpy; (b) reserve height with a skeleton during $navigating only — smoother but more code. My lean is (b), but it slightly contradicts my own "renders nothing when empty" rule in the spec. Fine to defer to Felix's taste during implementation.
## 🎨 Leonie Voss — UX Lead (self-review of the spec) _I authored the spec, so this is a close-reading for gaps I want Felix to catch during implementation rather than a design critique._ ### Observations - **Dark-mode file-type badge contrast is under-specified.** The spec section 07 flags it as a "verify" item but doesn't give exact dark-mode class values. That leaves room for Felix to ship `text-red-600 bg-red-100` against a `#0D1820` panel, which fails AA at ~3.1:1 for the text inside the badge. I should have given concrete dark-mode tokens in the impl-ref table. - **The visual-hole problem between upload completion and block population.** `invalidateAll()` in DropZone (line 97) triggers a full loader re-run. Between "banner appears" and "list block re-renders with fresh data" there's a perceptible gap on a slow connection — and if the user's queue was empty before the upload, the block was `null` and *becomes* populated. That's a layout shift of 400–600px on mobile exactly where the user is looking. Bad for seniors (disorienting). - **Backend sort field.** Spec says "most-recently-uploaded" but `DocumentService.findIncompleteDocuments` sorts by `createdAt DESC`. For fresh uploads these are identical. For docs created via Excel import (PLACEHOLDER lifecycle) they diverge. This only matters if a user wants "what I just dropped" to rank above "what got imported last month" — which they do. Flag for Felix. ### Recommendations 1. **Pin dark-mode badge classes in the impl-ref table.** Concrete values to drop into the spec's section 05 table row for "File-type badge": ``` PDF: light → bg-red-100 text-red-700 (7.2:1 AAA) dark → bg-red-950/60 text-red-300 (verify ≥ 4.5:1) JPG: light → bg-green-100 text-green-700 (8.1:1 AAA) dark → bg-green-950/60 text-green-300 (verify ≥ 4.5:1) TIF: light → bg-purple-100 text-purple-700 (7.9:1 AAA) dark → bg-purple-950/60 text-purple-300 (verify ≥ 4.5:1) ``` I'll amend the spec separately. For this issue: Felix, use these, and let axe confirm — don't guess. 2. **Reserve block height during `$navigating`.** If the block is `null` when empty, add a skeleton fallback while a fresh invalidation is in flight: ```svelte {#if $navigating && !topDocs.length} <div class="h-[360px] bg-surface/50 rounded-sm animate-pulse motion-reduce:animate-none" aria-hidden="true"></div> {:else if topDocs.length > 0} <EnrichmentBlock {topDocs} {totalCount} /> {/if} ``` Keeps the scroll position stable while new docs flow in. `motion-reduce:animate-none` respects `prefers-reduced-motion`. 3. **Banner copy for 1 vs. N docs.** Paraglide handles parameters but I didn't spec the singular/plural German grammar: ``` count = 1: "1 Dokument hochgeladen. Jetzt ergänzen, damit es durchsuchbar wird." count > 1: "{count} Dokumente hochgeladen. Jetzt ergänzen, damit sie durchsuchbar werden." ``` Two Paraglide keys: `upload_banner_singular` and `upload_banner_plural`. Don't let Felix write one key with ambiguous grammar. 4. **Row order on return-visits matters.** If a user comes back after enriching 3 of 12 docs, the 9 remaining should still be the oldest-uploaded-first-incomplete (FIFO for the user's mental "I'm working through the shoebox"). Current sort is `createdAt DESC` — *newest first* — which is right for "fresh uploads at top" but wrong for "continuing yesterday's shoebox." This is a real friction point for senior batch-uploaders. Worth raising as a product question: list-block sort is always newest-first, or user-preference-sticky? My instinct: newest-first is correct because the banner-driven "just-uploaded" case is the dominant flow, and the `/enrich` page can offer sort controls later (out of scope per spec). ### Open Decisions - **Block-height reservation during loading.** Adding the skeleton complicates the "renders null when empty" rule. Two options: (a) accept the layout shift — simple, occasionally jumpy; (b) reserve height with a skeleton during `$navigating` only — smoother but more code. My lean is (b), but it slightly contradicts my own "renders nothing when empty" rule in the spec. Fine to defer to Felix's taste during implementation.
Author
Owner

🗳️ Decision Queue — Action Required

4 decisions need your input before implementation starts.

Security

  • Permission level for /api/documents/incomplete (and retrofitted for -count and /next). Options:

    • (A) WRITE_ALL — only users who can actually enrich see the queue. Matches the existing frontend guard in enrich/+page.server.ts:11-15. Clean defense-in-depth. Nora's recommendation.
    • (B) READ_ALL — readers can see pending work to coordinate with writers. Makes sense only if your family has distinct reader-only accounts who need discoverability. Leaks incomplete document titles/IDs to read-only users.

    Raised by: Nora.

Architecture

  • Endpoint path naming for the new list endpoint. Options:

    • (A) Add GET /incomplete alongside existing /incomplete-count and /incomplete/next. Slightly inconsistent shape but zero breaking changes. Markus's preference.
    • (B) Refactor existing paths to /incomplete (list), /incomplete/count, /incomplete/next for consistency. Breaking change to /enrich/[id]/+page.server.ts:30 and :48. One-time fix, cleaner long-term.

    Raised by: Markus.

UX

  • Empty-state behavior during post-upload re-load. Options:

    • (A) Keep the spec's "renders nothing when empty" rule. Accept a brief layout shift (400–600px on mobile) between banner appearing and list block populating after invalidateAll(). Simpler, occasionally jumpy.
    • (B) Reserve block height with a loading skeleton during $navigating && !topDocs.length. Smoother for seniors, contradicts the spec's clean-empty rule slightly. Leonie's lean.

    Raised by: Leonie.

  • Playwright viewport matrix scope. Options:

    • (A) 320 / 768 / 1440 as specified in AC. Covers the dashboard grid's breakpoints.
    • (B) Add 1920 if the block's visual weight shifts at wider widths — but the grid is lg:grid-cols-[1fr_320px], so 1440+ renders identically. Probably unnecessary.

    Raised by: Sara.


These are the only items that need your input before Felix can start. All other feedback is concrete recommendations Felix will follow directly.

## 🗳️ Decision Queue — Action Required _4 decisions need your input before implementation starts._ ### Security - **Permission level for `/api/documents/incomplete` (and retrofitted for `-count` and `/next`).** Options: - **(A) `WRITE_ALL`** — only users who can actually enrich see the queue. Matches the existing frontend guard in `enrich/+page.server.ts:11-15`. Clean defense-in-depth. _Nora's recommendation._ - **(B) `READ_ALL`** — readers can see pending work to coordinate with writers. Makes sense only if your family has distinct reader-only accounts who need discoverability. Leaks incomplete document titles/IDs to read-only users. _Raised by: Nora._ ### Architecture - **Endpoint path naming for the new list endpoint.** Options: - **(A) Add `GET /incomplete`** alongside existing `/incomplete-count` and `/incomplete/next`. Slightly inconsistent shape but zero breaking changes. _Markus's preference._ - **(B) Refactor** existing paths to `/incomplete` (list), `/incomplete/count`, `/incomplete/next` for consistency. Breaking change to `/enrich/[id]/+page.server.ts:30` and :48. One-time fix, cleaner long-term. _Raised by: Markus._ ### UX - **Empty-state behavior during post-upload re-load.** Options: - **(A) Keep the spec's "renders nothing when empty" rule.** Accept a brief layout shift (400–600px on mobile) between banner appearing and list block populating after `invalidateAll()`. Simpler, occasionally jumpy. - **(B) Reserve block height with a loading skeleton** during `$navigating && !topDocs.length`. Smoother for seniors, contradicts the spec's clean-empty rule slightly. _Leonie's lean._ _Raised by: Leonie._ - **Playwright viewport matrix scope.** Options: - **(A) 320 / 768 / 1440** as specified in AC. Covers the dashboard grid's breakpoints. - **(B) Add 1920** if the block's visual weight shifts at wider widths — but the grid is `lg:grid-cols-[1fr_320px]`, so 1440+ renders identically. Probably unnecessary. _Raised by: Sara._ --- _These are the only items that need your input before Felix can start. All other feedback is concrete recommendations Felix will follow directly._
Author
Owner

📝 Correction to Markus's review — endpoint history

My earlier framing ("/enrich has been silently empty for some time") was wrong. Checked the git log:

  • Commit ddd811c6 (2026-04-19, "feat(dashboard): remove deprecated /incomplete and /recent-activity endpoints") removed GET /api/documents/incomplete one day ago. Message: "superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.)"
  • But the new /api/dashboard/* endpoints don't actually cover the incomplete-docs list case. The supersession was incomplete.
  • /enrich/+page.server.ts:18 still calls the now-deleted path — a missed cleanup from that same sweep.
  • The orphaned DashboardNeedsMetadata.svelte component is leftover from the same era.

So the story is: yesterday's dashboard redesign removed both the widget wiring AND the endpoint, on the assumption the new endpoints would cover it. They didn't. This issue is the follow-up that reinstates the widget and the endpoint it needs.

Implementation impact: none. The plan stays the same — re-add the endpoint (effectively restoring the deleted @GetMapping("/incomplete") block from ddd811c6), extend the DTO with uploadedAt and mimeType, and wire the widget. But the narrative in the Felix/Markus comments should read as "re-add, don't add" — it's a revert-and-extend, not a greenfield endpoint.

Nora's @RequirePermission point still stands: the restored endpoint should land with @RequirePermission(Permission.WRITE_ALL) (pending the Decision Queue answer), and the existing /incomplete-count + /incomplete/next should be retrofitted with the same annotation in the same PR.

## 📝 Correction to Markus's review — endpoint history My earlier framing ("`/enrich` has been silently empty for some time") was wrong. Checked the git log: - **Commit `ddd811c6`** (2026-04-19, "feat(dashboard): remove deprecated /incomplete and /recent-activity endpoints") removed `GET /api/documents/incomplete` one day ago. Message: _"superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.)"_ - But the new `/api/dashboard/*` endpoints don't actually cover the incomplete-docs list case. The supersession was incomplete. - `/enrich/+page.server.ts:18` still calls the now-deleted path — a missed cleanup from that same sweep. - The orphaned `DashboardNeedsMetadata.svelte` component is leftover from the same era. **So the story is:** yesterday's dashboard redesign removed both the widget wiring AND the endpoint, on the assumption the new endpoints would cover it. They didn't. This issue is the follow-up that reinstates the widget *and* the endpoint it needs. **Implementation impact: none.** The plan stays the same — re-add the endpoint (effectively restoring the deleted `@GetMapping("/incomplete")` block from `ddd811c6`), extend the DTO with `uploadedAt` and `mimeType`, and wire the widget. But the narrative in the Felix/Markus comments should read as "re-add, don't add" — it's a revert-and-extend, not a greenfield endpoint. Nora's `@RequirePermission` point still stands: the restored endpoint should land with `@RequirePermission(Permission.WRITE_ALL)` (pending the Decision Queue answer), and the existing `/incomplete-count` + `/incomplete/next` should be retrofitted with the same annotation in the same PR.
Author
Owner

📝 Correction #2 — PDF-only archive, simplification of spec

Clarification from @marcel: Familienarchiv only supports PDF documents. The other mime types the spec and DropZone reference are not part of the actual product scope.

What this changes in the plan

Backend DTO extension — smaller:

public record IncompleteDocumentDTO(
    @Schema(requiredMode = REQUIRED) UUID id,
    @Schema(requiredMode = REQUIRED) String title,
    @Schema(requiredMode = REQUIRED) Instant uploadedAt   // NEW — still needed for relative time
    // mimeType: dropped — always PDF
) {}

Frontend row anatomy — simpler:

  • Drop the multi-color file-type badge entirely. Use a single generic document icon on the left (or omit the left-side badge altogether — the title carries enough signal for a PDF-only archive).
  • Drop the MIME_BADGES allowlist Nora proposed. No routing needed.
  • Spec's impl-ref table row for "File-type badge" becomes obsolete or simplifies to a static icon.

What's no longer in scope for this issue:

  • Mime-type CSS-class routing (Nora's defensive allowlist concern — moot)
  • Leonie's dark-mode badge color contrast recommendations for red/green/purple (moot)
  • Backend mapper fetching mime type (moot)

What remains:

  • Re-add GET /api/documents/incomplete with @RequirePermission (pending decision queue)
  • Extend DTO with uploadedAt only
  • Wire the widget into +page.svelte between Resume strip and MissionControlStrip
  • Post-upload banner
  • Relative-time meta line
  • All a11y, viewport, and testing ACs

Separate concern flagged (out of scope for this issue)

DropZone.svelte:6 still declares:

const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];

If the PDF-only policy is firm, DropZone should reject non-PDFs at the client boundary (and the backend should reject them too, if it doesn't already). That's a separate cleanup — worth a dedicated issue to avoid scope-creep here.

Updated Decision Queue

The "empty-state behavior" and "permission level" decisions stand unchanged. The viewport matrix decision stands. The spec's Section 02 (row anatomy) needs a light amendment from Leonie to reflect the simpler PDF-only row — no implementation blocker.

## 📝 Correction #2 — PDF-only archive, simplification of spec Clarification from @marcel: **Familienarchiv only supports PDF documents.** The other mime types the spec and DropZone reference are not part of the actual product scope. ### What this changes in the plan **Backend DTO extension — smaller:** ```java public record IncompleteDocumentDTO( @Schema(requiredMode = REQUIRED) UUID id, @Schema(requiredMode = REQUIRED) String title, @Schema(requiredMode = REQUIRED) Instant uploadedAt // NEW — still needed for relative time // mimeType: dropped — always PDF ) {} ``` **Frontend row anatomy — simpler:** - Drop the multi-color file-type badge entirely. Use a single generic document icon on the left (or omit the left-side badge altogether — the title carries enough signal for a PDF-only archive). - Drop the MIME_BADGES allowlist Nora proposed. No routing needed. - Spec's impl-ref table row for "File-type badge" becomes obsolete or simplifies to a static icon. **What's no longer in scope for this issue:** - Mime-type CSS-class routing (Nora's defensive allowlist concern — moot) - Leonie's dark-mode badge color contrast recommendations for red/green/purple (moot) - Backend mapper fetching mime type (moot) **What remains:** - Re-add `GET /api/documents/incomplete` with `@RequirePermission` (pending decision queue) - Extend DTO with `uploadedAt` only - Wire the widget into `+page.svelte` between Resume strip and MissionControlStrip - Post-upload banner - Relative-time meta line - All a11y, viewport, and testing ACs ### Separate concern flagged (out of scope for this issue) `DropZone.svelte:6` still declares: ```typescript const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']; ``` If the PDF-only policy is firm, DropZone should reject non-PDFs at the client boundary (and the backend should reject them too, if it doesn't already). That's a **separate cleanup** — worth a dedicated issue to avoid scope-creep here. ### Updated Decision Queue The "empty-state behavior" and "permission level" decisions stand unchanged. The viewport matrix decision stands. The spec's Section 02 (row anatomy) needs a light amendment from Leonie to reflect the simpler PDF-only row — no implementation blocker.
Author
Owner

Decisions resolved (2026-04-20)

All four items from the Decision Queue are closed:

# Decision Resolution
1 Permission level for /incomplete* endpoints @RequirePermission(Permission.WRITE_ALL) on the new list endpoint AND retrofit to the existing /incomplete-count and /incomplete/next (Nora's A)
2 Endpoint path naming Leave as-is. Add GET /incomplete alongside the existing paths — no refactor (Markus's A)
3 Empty-state behavior during post-upload reload Reserve block height with a loading skeleton during $navigating && !topDocs.length (Leonie's B)
4 Playwright viewport matrix 320 / 768 / 1440 as specified in AC (Sara's A)

Updated plan summary (after both corrections + decisions)

  1. Backend (revert-and-extend): re-add GET /api/documents/incomplete from commit ddd811c6 with @RequirePermission(Permission.WRITE_ALL); retrofit the same annotation to /incomplete-count and /incomplete/next.
  2. DTO: extend IncompleteDocumentDTO with uploadedAt only (no mimeType — PDF-only archive).
  3. Frontend: wire DashboardNeedsMetadata.svelte into +page.svelte between Resume strip and MissionControlStrip; add skeleton during $navigating.
  4. Row anatomy: simplified — generic PDF icon (or no left badge), title, relative time, chevron.
  5. Banner: post-upload success banner with manual dismiss + 8s auto-dismiss, aria-live="polite", two Paraglide keys (upload_banner_singular / upload_banner_plural).
  6. Tests: per Sara's pyramid plan, including retrofit auth tests on the two existing endpoints.

Ready for Felix to start. The spec file (docs/specs/enrichment-list-block-spec.html) should get a small amendment from Leonie later to reflect the PDF-only simplification, but that's not an implementation blocker.

## ✅ Decisions resolved (2026-04-20) All four items from the Decision Queue are closed: | # | Decision | Resolution | |---|----------|-----------| | 1 | Permission level for `/incomplete*` endpoints | **`@RequirePermission(Permission.WRITE_ALL)`** on the new list endpoint AND retrofit to the existing `/incomplete-count` and `/incomplete/next` (Nora's A) | | 2 | Endpoint path naming | **Leave as-is.** Add `GET /incomplete` alongside the existing paths — no refactor (Markus's A) | | 3 | Empty-state behavior during post-upload reload | **Reserve block height with a loading skeleton during `$navigating && !topDocs.length`** (Leonie's B) | | 4 | Playwright viewport matrix | **320 / 768 / 1440** as specified in AC (Sara's A) | ### Updated plan summary (after both corrections + decisions) 1. **Backend (revert-and-extend):** re-add `GET /api/documents/incomplete` from commit `ddd811c6` with `@RequirePermission(Permission.WRITE_ALL)`; retrofit the same annotation to `/incomplete-count` and `/incomplete/next`. 2. **DTO:** extend `IncompleteDocumentDTO` with `uploadedAt` only (no `mimeType` — PDF-only archive). 3. **Frontend:** wire `DashboardNeedsMetadata.svelte` into `+page.svelte` between Resume strip and MissionControlStrip; add skeleton during `$navigating`. 4. **Row anatomy:** simplified — generic PDF icon (or no left badge), title, relative time, chevron. 5. **Banner:** post-upload success banner with manual dismiss + 8s auto-dismiss, `aria-live="polite"`, two Paraglide keys (`upload_banner_singular` / `upload_banner_plural`). 6. **Tests:** per Sara's pyramid plan, including retrofit auth tests on the two existing endpoints. Ready for Felix to start. The spec file (`docs/specs/enrichment-list-block-spec.html`) should get a small amendment from Leonie later to reflect the PDF-only simplification, but that's not an implementation blocker.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#296