As a user I want to see uploaded documents needing metadata on the dashboard so I can continue enriching after a batch upload #296
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.sveltecomponent still exists infrontend/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
/enrichwhen more than 5 are pending.Not a 4th column in MissionControlStrip, because:
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-reftable of exact Tailwind classes + pixel values:➡️
docs/specs/enrichment-list-block-spec.htmlKey design decisions
/enrichstays the canonical "long queue" page<a>, 72px mobile / 64px desktop (well above WCAG 2.2 AA 48px floor)Backend dependency
Extend
IncompleteDocumentDTO(currently onlyid+title):Small, cheap change. Without them the block still works, just without the relative-time meta line and the file-type badge.
Acceptance criteria
DashboardNeedsMetadata.svelteis re-wired into+page.sveltebetween Resume strip and MissionControlStrip<a>)length > 5nullIncompleteDocumentDTOextended withuploadedAtandmimeType; TypeScript types regenerated/enrich/{id}de/en/es(relative time, banner copy, footer link)Out of scope (separate issues later)
/enrich🏛️ Markus Keller — Senior Application Architect
Observations
DashboardNeedsMetadata.sveltevia 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:18callsapi.GET('/api/documents/incomplete')against a 404 — the/enrichpage has been silently empty for some time. Generated types confirm:frontend/src/lib/generated/api.tscontains only/api/documents/incomplete/nextand/api/documents/incomplete-count, no list path.DocumentService.findIncompleteDocuments(int size)(service line 541) already exists and returnsList<IncompleteDocumentDTO>sorted bycreatedAt DESC. The wiring gap is entirely in the controller.+page.server.ts) usesPromise.allSettledover 8 endpoint calls; adding a 9th for the incomplete list is trivial and matches the existing pattern.findIncompleteDocumentsfilters onmetadataComplete=false, not onDocumentStatus. 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
/enrich.Math.min(size, 200)) — never trust a client-supplied limit unbounded.uploadedAtandmimeTypebeing present. Doing them separately means the frontend lands withundefinedfields and the meta line never renders.Promise.allSettledarray. 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.DashboardNeedsMetadata.svelterenders the list; if the AC grows (banner, skeleton, empty-state handling), put those in a parentEnrichmentBlock.svelterather than bloating the existing component. One nameable visual region = one component.Open Decisions
/incomplete-countand/incomplete/next. Adding/incompleteas 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 beGET /incomplete,GET /incomplete?count=trueorGET /incomplete/countandGET /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/incompletelist; the inconsistency is minor and a refactor for tidiness isn't worth the risk on working code.🔧 Tobias Wendt — DevOps & Platform Engineer
Observations
Document.createdAtand 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 ofDocumentService.java.Recommendations
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:actions/upload-artifact@v4caps 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.
👨💻 Felix Brandt — Senior Fullstack Developer
Observations
GET /api/documents/incompletebut the endpoint doesn't exist./enrich/+page.server.ts:18is a silent 404. My TDD cycle starts there.DropZone.svelteholdsuploadMessagesas local$stateand 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),_hoursand_daysalready exist with Paraglide parameter support. Reuse, don't duplicate.IncompleteDocumentDTOis a record — extending it is one line plus the mapper onDocumentService.java:545.mimeTypecomes from the stored file entry,uploadedAtisdoc.getCreatedAt().DashboardNeedsMetadata.svelte(DashboardNeedsMetadata.svelte.spec.ts) — I'll extend rather than replace.Recommendations
Ordered TDD cycle (one commit per red/green):
DocumentControllerTest.should_return_incomplete_documents_sorted_by_most_recent(). Then green — add@GetMapping("/incomplete")with size cap. Mirror the@RequirePermissiondecision from Nora's comment.uploadedAtandmimeType. Then green — extend the record and the mapper.mvnw spring-boot:run --spring.profiles.active=dev+npm run generate:apiin frontend.DashboardNeedsMetadata.svelteto match the spec's row anatomy.Promise.allSettledentry. Then green — add the fetch.EnrichmentBlock.svelteparent fromDashboardNeedsMetadata.svelteper Markus.Banner state lifting — clear winner: callback prop from DropZone.
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.
The current
incompleteDocsprop conflates "list to render" with "total count" — the spec needs both, andtopDocs.length≠totalCountwhen more than 5 are pending.Relative-time helper: extract once, don't duplicate.
Pure function, trivially unit-testable with an injected
now— no clock flake.Open Decisions
None — all the tradeoffs above have a clear winner.
🔐 Nora "NullX" Steiner — Application Security Engineer
Observations
DocumentController.java:195(/incomplete-count) and:200(/incomplete/next) have no@RequirePermissionannotation. 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.READ_ALL— can enumerate incomplete document titles and IDs via/incomplete-count(count only, minor) and/incomplete/next(fullDocumententity including sender, tags, file path — more sensitive). CWE-285 (Improper Authorization)./incompletelist endpoint will inherit this gap unless explicitly annotated. The AC doesn't mention permissions.enrich/+page.server.ts:11-15already implements the correct policy in the UI layer: redirects non-WRITE_ALLusers to/. The server-side permission annotation should mirror that intent — defense in depth.Recommendations
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.Regression test (mandatory, per my standing rule with Sara):
Plus the 401 (unauthenticated) variant. Both existing endpoints get the same tests retroactively — their lack of annotation was already an un-tested authorization bug.
mimeTypehandling in the frontend: do not trust arbitrary mime-type strings for CSS-class routing. The spec's file-type badge usesmime → colormapping (red/PDF, green/JPG, purple/TIF). Build that mapping as a hard allowlist and fall back to a generic "DOC" badge for anything unknown: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.
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
WRITE_ALLis my recommendation (matches intent: only writers need to see the queue). An alternative isREAD_ALLwith 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.🧪 Sara Holt — Senior QA Engineer
Observations
DashboardNeedsMetadata.svelte.spec.tstest file should be updated, not replaced. I'll check it during review to make sure it isn't a snapshot test masquerading as coverage.#0D1820background needs thecolor-contrastrule explicitly enabled (it's default-on inwcag2aa, 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):
Concrete flake-prevention patterns:
Freeze the clock in Vitest for relative-time tests:
Banner 8s dismiss — use Vitest fake timers, not
waitFor(8000):Playwright banner assertion — assert via role + text, don't rely on CSS class names that may change:
axe run in both themes already in AC — add explicit
disableRulesguard: make sure no one suppressescolor-contraston badges "because they're decorative."Open Decisions
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.DashboardNeedsMetadata.svelte.spec.tsbe 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.🎨 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
text-red-600 bg-red-100against a#0D1820panel, 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.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 wasnulland becomes populated. That's a layout shift of 400–600px on mobile exactly where the user is looking. Bad for seniors (disorienting).DocumentService.findIncompleteDocumentssorts bycreatedAt 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
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":
I'll amend the spec separately. For this issue: Felix, use these, and let axe confirm — don't guess.
Reserve block height during
$navigating. If the block isnullwhen empty, add a skeleton fallback while a fresh invalidation is in flight:Keeps the scroll position stable while new docs flow in.
motion-reduce:animate-nonerespectsprefers-reduced-motion.Banner copy for 1 vs. N docs. Paraglide handles parameters but I didn't spec the singular/plural German grammar:
Two Paraglide keys:
upload_banner_singularandupload_banner_plural. Don't let Felix write one key with ambiguous grammar.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/enrichpage can offer sort controls later (out of scope per spec).Open Decisions
$navigatingonly — 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.🗳️ Decision Queue — Action Required
4 decisions need your input before implementation starts.
Security
Permission level for
/api/documents/incomplete(and retrofitted for-countand/next). Options:WRITE_ALL— only users who can actually enrich see the queue. Matches the existing frontend guard inenrich/+page.server.ts:11-15. Clean defense-in-depth. Nora's recommendation.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:
GET /incompletealongside existing/incomplete-countand/incomplete/next. Slightly inconsistent shape but zero breaking changes. Markus's preference./incomplete(list),/incomplete/count,/incomplete/nextfor consistency. Breaking change to/enrich/[id]/+page.server.ts:30and :48. One-time fix, cleaner long-term.Raised by: Markus.
UX
Empty-state behavior during post-upload re-load. Options:
invalidateAll(). Simpler, occasionally jumpy.$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:
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.
📝 Correction to Markus's review — endpoint history
My earlier framing ("
/enrichhas been silently empty for some time") was wrong. Checked the git log:ddd811c6(2026-04-19, "feat(dashboard): remove deprecated /incomplete and /recent-activity endpoints") removedGET /api/documents/incompleteone day ago. Message: "superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.)"/api/dashboard/*endpoints don't actually cover the incomplete-docs list case. The supersession was incomplete./enrich/+page.server.ts:18still calls the now-deleted path — a missed cleanup from that same sweep.DashboardNeedsMetadata.sveltecomponent 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 fromddd811c6), extend the DTO withuploadedAtandmimeType, 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
@RequirePermissionpoint still stands: the restored endpoint should land with@RequirePermission(Permission.WRITE_ALL)(pending the Decision Queue answer), and the existing/incomplete-count+/incomplete/nextshould be retrofitted with the same annotation in the same PR.📝 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:
Frontend row anatomy — simpler:
What's no longer in scope for this issue:
What remains:
GET /api/documents/incompletewith@RequirePermission(pending decision queue)uploadedAtonly+page.sveltebetween Resume strip and MissionControlStripSeparate concern flagged (out of scope for this issue)
DropZone.svelte:6still declares: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.
✅ Decisions resolved (2026-04-20)
All four items from the Decision Queue are closed:
/incomplete*endpoints@RequirePermission(Permission.WRITE_ALL)on the new list endpoint AND retrofit to the existing/incomplete-countand/incomplete/next(Nora's A)GET /incompletealongside the existing paths — no refactor (Markus's A)$navigating && !topDocs.length(Leonie's B)Updated plan summary (after both corrections + decisions)
GET /api/documents/incompletefrom commitddd811c6with@RequirePermission(Permission.WRITE_ALL); retrofit the same annotation to/incomplete-countand/incomplete/next.IncompleteDocumentDTOwithuploadedAtonly (nomimeType— PDF-only archive).DashboardNeedsMetadata.svelteinto+page.sveltebetween Resume strip and MissionControlStrip; add skeleton during$navigating.aria-live="polite", two Paraglide keys (upload_banner_singular/upload_banner_plural).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.