From d2fc452c1adcdc78cec350fcf13c561916cf5aef Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 20:58:15 +0200 Subject: [PATCH 01/19] feat(dto): add uploadedAt to IncompleteDocumentDTO Mapper populates uploadedAt from Document.createdAt so the dashboard enrichment block can show a relative-time meta line ("vor 2 Min.") per issue #296. LocalDateTime matches the convention used by NotificationDTO, DocumentVersionSummary and InviteListItemDTO. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dto/IncompleteDocumentDTO.java | 4 +++- .../familienarchiv/service/DocumentService.java | 2 +- .../service/DocumentServiceTest.java | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/IncompleteDocumentDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/IncompleteDocumentDTO.java index 38cf4e93..5edd7492 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/dto/IncompleteDocumentDTO.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/IncompleteDocumentDTO.java @@ -2,9 +2,11 @@ package org.raddatz.familienarchiv.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; import java.util.UUID; public record IncompleteDocumentDTO( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id, - @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime uploadedAt ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index 21618f71..fc26dcc3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -542,7 +542,7 @@ public class DocumentService { PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt")); return documentRepository.findByMetadataCompleteFalse(pageable) .stream() - .map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle())) + .map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle(), doc.getCreatedAt())) .toList(); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index 6a3aec0f..7eefde78 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -390,6 +390,22 @@ class DocumentServiceTest { assertThat(result.get(0).title()).isEqualTo("Unvollständig"); } + @Test + void findIncompleteDocuments_mapsUploadedAtFromCreatedAt() { + java.time.LocalDateTime createdAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0); + Document doc = Document.builder() + .id(UUID.randomUUID()) + .title("Recent") + .createdAt(createdAt) + .build(); + when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(doc))); + + List result = documentService.findIncompleteDocuments(3); + + assertThat(result.get(0).uploadedAt()).isEqualTo(createdAt); + } + @Test void findIncompleteDocuments_passesSizeToPageable() { when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class))) -- 2.49.1 From bc3a268f66cf03ba3cd6cc9a10e8bc6085b01df8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:01:54 +0200 Subject: [PATCH 02/19] feat(documents): re-add GET /api/documents/incomplete Restores the list endpoint removed in ddd811c6 and caps size at 200. The dashboard enrichment block (issue #296) and /enrich page both consume it; /enrich was silently 404ing since the deletion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/DocumentController.java | 8 +++++++ .../controller/DocumentControllerTest.java | 23 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index bbef336d..7d591c3f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.dto.DocumentSearchResult; import org.raddatz.familienarchiv.dto.DocumentUpdateDTO; import org.raddatz.familienarchiv.dto.TagOperator; import org.raddatz.familienarchiv.dto.DocumentVersionSummary; +import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; @@ -197,6 +198,13 @@ public class DocumentController { return Map.of("count", documentService.getIncompleteCount()); } + @GetMapping("/incomplete") + public List getIncomplete( + @Parameter(description = "Maximum number of results (server caps at 200)") + @RequestParam(defaultValue = "50") int size) { + return documentService.findIncompleteDocuments(Math.min(size, 200)); + } + @GetMapping("/incomplete/next") public ResponseEntity getNextIncomplete(@RequestParam UUID excludeId) { return documentService.findNextIncompleteDocument(excludeId) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 6b8b5fbc..842fe43b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -391,14 +391,27 @@ class DocumentControllerTest { .andExpect(jsonPath("$.count").value(3)); } - // ─── GET /api/documents/incomplete (removed — superseded by dashboard) ──── + // ─── GET /api/documents/incomplete ─────────────────────────────────────── @Test - @WithMockUser - void getIncomplete_endpointRemoved() throws Exception { - // The path hits /{id} and fails UUID conversion — not a 200 anymore + void getIncomplete_returns401_whenUnauthenticated() throws Exception { mockMvc.perform(get("/api/documents/incomplete")) - .andExpect(status().is4xxClientError()); + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(authorities = {"WRITE_ALL"}) + void getIncomplete_returns200_forWriter_withDTOList() throws Exception { + UUID id = UUID.randomUUID(); + java.time.LocalDateTime uploadedAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0); + var dto = new org.raddatz.familienarchiv.dto.IncompleteDocumentDTO(id, "Unvollständig", uploadedAt); + when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto)); + + mockMvc.perform(get("/api/documents/incomplete")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(id.toString())) + .andExpect(jsonPath("$[0].title").value("Unvollständig")) + .andExpect(jsonPath("$[0].uploadedAt").exists()); } // ─── GET /api/documents/incomplete/next ────────────────────────────────── -- 2.49.1 From 2c5cfcedbc53be5c998370edeedfdf737c5996e7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:05:35 +0200 Subject: [PATCH 03/19] feat(documents): gate /incomplete behind WRITE_ALL permission Only users who can enrich documents should see the queue. Mirrors the frontend guard in enrich/+page.server.ts and closes the CWE-285 gap Nora flagged on issue #296. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../familienarchiv/controller/DocumentController.java | 1 + .../familienarchiv/controller/DocumentControllerTest.java | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index 7d591c3f..359e8355 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -199,6 +199,7 @@ public class DocumentController { } @GetMapping("/incomplete") + @RequirePermission(Permission.WRITE_ALL) public List getIncomplete( @Parameter(description = "Maximum number of results (server caps at 200)") @RequestParam(defaultValue = "50") int size) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 842fe43b..4e6c9c04 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -414,6 +414,13 @@ class DocumentControllerTest { .andExpect(jsonPath("$[0].uploadedAt").exists()); } + @Test + @WithMockUser(authorities = "READ_ALL") + void getIncomplete_returns403_forReaderOnly() throws Exception { + mockMvc.perform(get("/api/documents/incomplete")) + .andExpect(status().isForbidden()); + } + // ─── GET /api/documents/incomplete/next ────────────────────────────────── @Test -- 2.49.1 From 758c7087660a9f51c2a370e158a1e039efe68652 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:09:10 +0200 Subject: [PATCH 04/19] test(documents): lock /incomplete size cap at 200 Regression test proving the controller clamps client-supplied size values server-side, closing the unbounded-limit concern Markus flagged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/DocumentControllerTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 4e6c9c04..f226f8f4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -421,6 +421,17 @@ class DocumentControllerTest { .andExpect(status().isForbidden()); } + @Test + @WithMockUser(authorities = "WRITE_ALL") + void getIncomplete_capsSizeAt200() throws Exception { + when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of()); + + mockMvc.perform(get("/api/documents/incomplete").param("size", "9999")) + .andExpect(status().isOk()); + + verify(documentService).findIncompleteDocuments(200); + } + // ─── GET /api/documents/incomplete/next ────────────────────────────────── @Test -- 2.49.1 From 47859e5a9bb73a4c5f7c4fcb2eb255c520fd7d1c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:14:24 +0200 Subject: [PATCH 05/19] feat(documents): retrofit WRITE_ALL guard on /incomplete-count + /incomplete/next Closes the CWE-285 gap Nora flagged on issue #296: both endpoints expose enrichment-queue information that only writers should see. Brings them in line with the new /incomplete list endpoint and every other write-path under DocumentController. Frontend callers (/enrich/[id]/+page.server.ts) already gate on WRITE_ALL at the route level, so no client-side change is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/DocumentController.java | 2 ++ .../controller/DocumentControllerTest.java | 21 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index 359e8355..e5f0bb17 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -194,6 +194,7 @@ public class DocumentController { } @GetMapping("/incomplete-count") + @RequirePermission(Permission.WRITE_ALL) public Map getIncompleteCount() { return Map.of("count", documentService.getIncompleteCount()); } @@ -207,6 +208,7 @@ public class DocumentController { } @GetMapping("/incomplete/next") + @RequirePermission(Permission.WRITE_ALL) public ResponseEntity getNextIncomplete(@RequestParam UUID excludeId) { return documentService.findNextIncompleteDocument(excludeId) .map(ResponseEntity::ok) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index f226f8f4..9976a83f 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -382,7 +382,7 @@ class DocumentControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void getIncompleteCount_returns200_withCount() throws Exception { when(documentService.getIncompleteCount()).thenReturn(3L); @@ -391,6 +391,13 @@ class DocumentControllerTest { .andExpect(jsonPath("$.count").value(3)); } + @Test + @WithMockUser(authorities = "READ_ALL") + void getIncompleteCount_returns403_forReaderOnly() throws Exception { + mockMvc.perform(get("/api/documents/incomplete-count")) + .andExpect(status().isForbidden()); + } + // ─── GET /api/documents/incomplete ─────────────────────────────────────── @Test @@ -442,7 +449,7 @@ class DocumentControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "WRITE_ALL") void getNextIncomplete_returns200_whenNextExists() throws Exception { UUID excludeId = UUID.randomUUID(); Document next = Document.builder() @@ -456,7 +463,15 @@ class DocumentControllerTest { } @Test - @WithMockUser + @WithMockUser(authorities = "READ_ALL") + void getNextIncomplete_returns403_forReaderOnly() throws Exception { + mockMvc.perform(get("/api/documents/incomplete/next") + .param("excludeId", UUID.randomUUID().toString())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(authorities = "WRITE_ALL") void getNextIncomplete_returns204_whenNoneRemain() throws Exception { UUID excludeId = UUID.randomUUID(); when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty()); -- 2.49.1 From 46fe3655aba45ca39b89482775ad114c0f238127 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:19:46 +0200 Subject: [PATCH 06/19] chore(frontend): regenerate openapi types for /api/documents/incomplete Picks up the restored list endpoint and the new uploadedAt field on IncompleteDocumentDTO (issue #296). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/generated/api.ts | 56 ++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index d15c12e1..3a858315 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -1140,6 +1140,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/documents/incomplete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getIncomplete"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/incomplete/next": { parameters: { query?: never; @@ -1799,17 +1815,17 @@ export interface components { /** Format: uuid */ id?: string; displayName?: string; - personType?: string; - firstName?: string; - lastName?: string; /** Format: int64 */ documentCount?: number; + firstName?: string; + lastName?: string; /** Format: int32 */ birthYear?: number; /** Format: int32 */ deathYear?: number; alias?: string; notes?: string; + personType?: string; }; SenderModel: { /** Format: uuid */ @@ -1877,10 +1893,10 @@ export interface components { timeout?: number; }; PageNotificationDTO: { - /** Format: int64 */ - totalElements?: number; /** Format: int32 */ totalPages?: number; + /** Format: int64 */ + totalElements?: number; pageable?: components["schemas"]["PageableObject"]; first?: boolean; last?: boolean; @@ -1979,6 +1995,13 @@ export interface components { summarySnippet?: string; summaryOffsets: components["schemas"]["MatchOffset"][]; }; + IncompleteDocumentDTO: { + /** Format: uuid */ + id: string; + title: string; + /** Format: date-time */ + uploadedAt: string; + }; DashboardResumeDTO: { /** Format: uuid */ documentId: string; @@ -4223,6 +4246,29 @@ export interface operations { }; }; }; + getIncomplete: { + parameters: { + query?: { + /** @description Maximum number of results (server caps at 200) */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["IncompleteDocumentDTO"][]; + }; + }; + }; + }; getNextIncomplete: { parameters: { query: { -- 2.49.1 From d5d1a463b81df0642c623aac30b5ac30210833da Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:22:59 +0200 Subject: [PATCH 07/19] feat(frontend): add relativeTimeDe helper for dashboard meta lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure function with injectable now — lets the dashboard enrichment block render "vor 2 Min." / "vor 3 Std." / "vor 2 Tagen" without clock-based test flakiness. Reuses the existing comment_time_minutes / _hours / _days Paraglide keys, no new translations needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/relativeTime.spec.ts | 34 +++++++++++++++++++++++++++ frontend/src/lib/relativeTime.ts | 8 +++++++ 2 files changed, 42 insertions(+) create mode 100644 frontend/src/lib/relativeTime.spec.ts create mode 100644 frontend/src/lib/relativeTime.ts diff --git a/frontend/src/lib/relativeTime.spec.ts b/frontend/src/lib/relativeTime.spec.ts new file mode 100644 index 00000000..9373986e --- /dev/null +++ b/frontend/src/lib/relativeTime.spec.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { relativeTimeDe } from './relativeTime'; + +const NOW = new Date('2026-04-20T12:00:00Z'); + +describe('relativeTimeDe', () => { + it('returns minutes for a gap under one hour', () => { + const from = new Date('2026-04-20T11:58:00Z'); + expect(relativeTimeDe(from, NOW)).toContain('2'); + expect(relativeTimeDe(from, NOW)).toMatch(/Minute/i); + }); + + it('returns hours for a gap between 1 and 24 hours', () => { + const from = new Date('2026-04-20T09:00:00Z'); + expect(relativeTimeDe(from, NOW)).toContain('3'); + expect(relativeTimeDe(from, NOW)).toMatch(/Stunde/i); + }); + + it('returns days for a gap of 24 hours or more', () => { + const from = new Date('2026-04-18T12:00:00Z'); + expect(relativeTimeDe(from, NOW)).toContain('2'); + expect(relativeTimeDe(from, NOW)).toMatch(/Tag/i); + }); + + it('rounds minutes to the nearest whole number', () => { + const from = new Date('2026-04-20T11:58:20Z'); + expect(relativeTimeDe(from, NOW)).toContain('2'); + }); + + it('handles zero gap as 0 minutes', () => { + expect(relativeTimeDe(NOW, NOW)).toMatch(/0/); + expect(relativeTimeDe(NOW, NOW)).toMatch(/Minute/i); + }); +}); diff --git a/frontend/src/lib/relativeTime.ts b/frontend/src/lib/relativeTime.ts new file mode 100644 index 00000000..1c78367a --- /dev/null +++ b/frontend/src/lib/relativeTime.ts @@ -0,0 +1,8 @@ +import * as m from '$lib/paraglide/messages.js'; + +export function relativeTimeDe(from: Date, now: Date = new Date()): string { + const minutes = Math.round((now.getTime() - from.getTime()) / 60_000); + if (minutes < 60) return m.comment_time_minutes({ count: minutes }); + if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) }); + return m.comment_time_days({ count: Math.round(minutes / 1440) }); +} -- 2.49.1 From 727569aa32a8676bdf0cace2d4622f606211003f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:25:17 +0200 Subject: [PATCH 08/19] i18n: add upload-banner + enrichment-block keys Singular/plural banner copy, a count-aware "show all" footer link, and the dismiss aria-label for the new dashboard enrichment-list-block (issue #296). Covers de / en / es. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/messages/de.json | 5 +++++ frontend/messages/en.json | 5 +++++ frontend/messages/es.json | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 253449f6..83cd959a 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -415,6 +415,11 @@ "dashboard_notification_replied": "hat geantwortet", "dashboard_needs_metadata_heading": "Metadaten fehlen", "dashboard_needs_metadata_show_all": "Alle anzeigen", + "dashboard_needs_metadata_show_all_count": "Alle {count} anzeigen →", + "upload_banner_singular": "1 Dokument hochgeladen.", + "upload_banner_plural": "{count} Dokumente hochgeladen.", + "upload_banner_cta": "Jetzt ergänzen →", + "upload_banner_close": "Benachrichtigung schließen", "dashboard_recent_heading": "Zuletzt aktiv", "dashboard_stats_documents": "Dokumente", "dashboard_stats_persons": "Personen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 80be4863..e623c96c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -415,6 +415,11 @@ "dashboard_notification_replied": "replied", "dashboard_needs_metadata_heading": "Missing Metadata", "dashboard_needs_metadata_show_all": "Show all", + "dashboard_needs_metadata_show_all_count": "Show all {count} →", + "upload_banner_singular": "1 document uploaded.", + "upload_banner_plural": "{count} documents uploaded.", + "upload_banner_cta": "Enrich now →", + "upload_banner_close": "Dismiss notification", "dashboard_recent_heading": "Recent Activity", "dashboard_stats_documents": "Documents", "dashboard_stats_persons": "Persons", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 84e2cfeb..6b0ba5f0 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -415,6 +415,11 @@ "dashboard_notification_replied": "respondió", "dashboard_needs_metadata_heading": "Metadatos incompletos", "dashboard_needs_metadata_show_all": "Ver todos", + "dashboard_needs_metadata_show_all_count": "Ver los {count} →", + "upload_banner_singular": "1 documento subido.", + "upload_banner_plural": "{count} documentos subidos.", + "upload_banner_cta": "Completar ahora →", + "upload_banner_close": "Cerrar notificación", "dashboard_recent_heading": "Actividad reciente", "dashboard_stats_documents": "Documentos", "dashboard_stats_persons": "Personas", -- 2.49.1 From 01e72611f04429ddc92f5520d825492ce2244d38 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:34:23 +0200 Subject: [PATCH 09/19] feat(dashboard): redesign needs-metadata with row anatomy + totalCount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches to two props — topDocs (max 5, capped by caller) and totalCount — so the footer link can surface "Alle 12 anzeigen →" even when only 5 items are shown. Each row gets a generic document icon, title, relative upload time and a chevron, wrapped in a single per the issue spec. Still returns null when topDocs is empty, keeping the empty dashboard clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/DashboardNeedsMetadata.svelte | 66 ++++++++++++++----- .../DashboardNeedsMetadata.svelte.spec.ts | 56 +++++++++------- 2 files changed, 83 insertions(+), 39 deletions(-) diff --git a/frontend/src/lib/components/DashboardNeedsMetadata.svelte b/frontend/src/lib/components/DashboardNeedsMetadata.svelte index cb197418..d7203a8b 100644 --- a/frontend/src/lib/components/DashboardNeedsMetadata.svelte +++ b/frontend/src/lib/components/DashboardNeedsMetadata.svelte @@ -1,37 +1,73 @@ -{#if incompleteDocs.length > 0} +{#if topDocs.length > 0}

{m.dashboard_needs_metadata_heading()}

- {#each incompleteDocs as doc (doc.id)} -
{/if} diff --git a/frontend/src/lib/components/DashboardNeedsMetadata.svelte.spec.ts b/frontend/src/lib/components/DashboardNeedsMetadata.svelte.spec.ts index ee2fdeb8..b08e32ff 100644 --- a/frontend/src/lib/components/DashboardNeedsMetadata.svelte.spec.ts +++ b/frontend/src/lib/components/DashboardNeedsMetadata.svelte.spec.ts @@ -6,44 +6,52 @@ import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte'; afterEach(cleanup); -type IncompleteDocumentDTO = { - id: string; - title: string; -}; +type IncompleteDoc = { id: string; title: string; uploadedAt: string }; -function makeDoc(id: string, title: string): IncompleteDocumentDTO { - return { id, title }; +function makeDoc(id: string, title: string, uploadedAt = '2026-04-20T12:00:00'): IncompleteDoc { + return { id, title, uploadedAt }; } describe('DashboardNeedsMetadata', () => { - it('renders nothing when incompleteDocs is empty', async () => { - render(DashboardNeedsMetadata, { incompleteDocs: [] }); + it('renders nothing when topDocs is empty', async () => { + render(DashboardNeedsMetadata, { topDocs: [], totalCount: 0 }); const widget = page.getByTestId('dashboard-needs-metadata'); await expect.element(widget).not.toBeInTheDocument(); }); - it('shows the widget when incompleteDocs are present', async () => { - render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Taufschein')] }); - const widget = page.getByTestId('dashboard-needs-metadata'); - await expect.element(widget).toBeInTheDocument(); + it('shows the widget when topDocs is present', async () => { + render(DashboardNeedsMetadata, { topDocs: [makeDoc('d1', 'Taufschein')], totalCount: 1 }); + await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument(); }); - it('renders a link to /enrich/{id} for each document', async () => { + it('renders one link per row pointing at /enrich/{id}', async () => { const docs = [makeDoc('d1', 'Taufschein'), makeDoc('d2', 'Heiratsurkunde')]; - render(DashboardNeedsMetadata, { incompleteDocs: docs }); - const links = page.getByRole('link'); - await expect.element(links.nth(0)).toHaveAttribute('href', '/enrich/d1'); - await expect.element(links.nth(1)).toHaveAttribute('href', '/enrich/d2'); + render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 2 }); + await expect + .element(page.getByRole('link', { name: /Taufschein/ })) + .toHaveAttribute('href', '/enrich/d1'); + await expect + .element(page.getByRole('link', { name: /Heiratsurkunde/ })) + .toHaveAttribute('href', '/enrich/d2'); }); - it('shows the document title in each row', async () => { - render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Sterbeurkunde 1930')] }); - await expect.element(page.getByText('Sterbeurkunde 1930')).toBeInTheDocument(); + it('hides the footer link when totalCount is 5 or fewer', async () => { + const docs = Array.from({ length: 5 }, (_, i) => makeDoc(`d${i}`, `Dok ${i}`)); + render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 5 }); + const footer = page.getByRole('link', { name: /Alle/i }); + await expect.element(footer).not.toBeInTheDocument(); }); - it('shows a "Alle anzeigen" link to /enrich', async () => { - render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Dok')] }); - const allLink = page.getByRole('link', { name: /Alle anzeigen/i }); - await expect.element(allLink).toHaveAttribute('href', '/enrich'); + it('shows the footer link with totalCount when totalCount > 5', async () => { + const docs = Array.from({ length: 5 }, (_, i) => makeDoc(`d${i}`, `Dok ${i}`)); + render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 12 }); + const footer = page.getByRole('link', { name: /12/ }); + await expect.element(footer).toHaveAttribute('href', '/enrich'); + }); + + it('uses totalCount in the footer even when topDocs has fewer items', async () => { + const docs = [makeDoc('d1', 'Only one')]; + render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 }); + await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument(); }); }); -- 2.49.1 From b29125615f9b00c2bd8aa966c0e9c2f3ab015579 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:53:28 +0200 Subject: [PATCH 10/19] feat(dashboard): add UploadSuccessBanner component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transient post-upload banner for issue #296: singular/plural German copy, aria-live=polite for screen readers, manual X dismiss, 8s auto-dismiss. "Jetzt ergänzen →" CTA links directly to /enrich so seniors can continue straight into the enrichment flow after a batch upload. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/components/UploadSuccessBanner.svelte | 62 +++++++++++++++++++ .../UploadSuccessBanner.svelte.spec.ts | 57 +++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 frontend/src/lib/components/UploadSuccessBanner.svelte create mode 100644 frontend/src/lib/components/UploadSuccessBanner.svelte.spec.ts diff --git a/frontend/src/lib/components/UploadSuccessBanner.svelte b/frontend/src/lib/components/UploadSuccessBanner.svelte new file mode 100644 index 00000000..e10b2f04 --- /dev/null +++ b/frontend/src/lib/components/UploadSuccessBanner.svelte @@ -0,0 +1,62 @@ + + +
+ +

+ {message} + + {m.upload_banner_cta()} + +

+ +
diff --git a/frontend/src/lib/components/UploadSuccessBanner.svelte.spec.ts b/frontend/src/lib/components/UploadSuccessBanner.svelte.spec.ts new file mode 100644 index 00000000..bed83d9a --- /dev/null +++ b/frontend/src/lib/components/UploadSuccessBanner.svelte.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import UploadSuccessBanner from './UploadSuccessBanner.svelte'; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('UploadSuccessBanner', () => { + it('renders singular copy for count of 1', async () => { + render(UploadSuccessBanner, { count: 1, onClose: () => {} }); + const status = page.getByRole('status'); + await expect.element(status).toBeInTheDocument(); + await expect.element(status).toHaveTextContent(/1 Dokument/); + }); + + it('renders plural copy for count greater than 1', async () => { + render(UploadSuccessBanner, { count: 3, onClose: () => {} }); + await expect.element(page.getByRole('status')).toHaveTextContent(/3 Dokumente/); + }); + + it('exposes role=status with aria-live polite', async () => { + render(UploadSuccessBanner, { count: 1, onClose: () => {} }); + await expect.element(page.getByRole('status')).toHaveAttribute('aria-live', 'polite'); + }); + + it('renders a CTA link to /enrich', async () => { + render(UploadSuccessBanner, { count: 2, onClose: () => {} }); + await expect + .element(page.getByRole('link', { name: /ergänzen/i })) + .toHaveAttribute('href', '/enrich'); + }); + + it('invokes onClose when the close button is clicked', async () => { + const onClose = vi.fn(); + render(UploadSuccessBanner, { count: 1, onClose }); + const button = document.querySelector( + '[data-testid="upload-banner-close"]' + ) as HTMLButtonElement | null; + expect(button).not.toBeNull(); + button?.click(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('auto-dismisses after 8000ms', async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + render(UploadSuccessBanner, { count: 1, onClose }); + vi.advanceTimersByTime(7999); + expect(onClose).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); -- 2.49.1 From e824e23c8cd2513fca7aa3aab6c5109b057fe700 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 21:58:05 +0200 Subject: [PATCH 11/19] feat(dashboard): add EnrichmentBlock wrapper component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes UploadSuccessBanner + DashboardNeedsMetadata and reserves a 360px skeleton while \$navigating re-runs the loader with a fresh incomplete list. Prevents the layout-shift jump after a batch upload (Leonie's resolved decision #3 on issue #296). Renders nothing when there is nothing to show — keeps the clean empty dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/EnrichmentBlock.svelte | 39 ++++++++++++ .../components/EnrichmentBlock.svelte.spec.ts | 61 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 frontend/src/lib/components/EnrichmentBlock.svelte create mode 100644 frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts diff --git a/frontend/src/lib/components/EnrichmentBlock.svelte b/frontend/src/lib/components/EnrichmentBlock.svelte new file mode 100644 index 00000000..2743329f --- /dev/null +++ b/frontend/src/lib/components/EnrichmentBlock.svelte @@ -0,0 +1,39 @@ + + +{#if showBlock} +
+ {#if bannerCount > 0} + + {/if} + {#if topDocs.length > 0} + + {:else if showSkeleton} + + {/if} +
+{/if} diff --git a/frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts b/frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts new file mode 100644 index 00000000..be187cf5 --- /dev/null +++ b/frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import EnrichmentBlock from './EnrichmentBlock.svelte'; + +vi.mock('$app/stores', async () => { + const { writable } = await import('svelte/store'); + return { navigating: writable(null) }; +}); + +afterEach(cleanup); + +type Doc = { id: string; title: string; uploadedAt: string }; + +function doc(id: string, title = 'Doc'): Doc { + return { id, title, uploadedAt: '2026-04-20T12:00:00' }; +} + +describe('EnrichmentBlock', () => { + it('renders nothing when topDocs is empty and banner count is 0', async () => { + render(EnrichmentBlock, { + topDocs: [], + totalCount: 0, + bannerCount: 0, + onBannerClose: vi.fn() + }); + await expect.element(page.getByTestId('enrichment-block')).not.toBeInTheDocument(); + }); + + it('renders the list component when topDocs is non-empty', async () => { + render(EnrichmentBlock, { + topDocs: [doc('d1')], + totalCount: 1, + bannerCount: 0, + onBannerClose: vi.fn() + }); + await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument(); + }); + + it('renders the banner when bannerCount > 0', async () => { + render(EnrichmentBlock, { + topDocs: [], + totalCount: 0, + bannerCount: 3, + onBannerClose: vi.fn() + }); + await expect.element(page.getByRole('status')).toBeInTheDocument(); + }); + + it('composes banner + list when both are present', async () => { + render(EnrichmentBlock, { + topDocs: [doc('d1')], + totalCount: 1, + bannerCount: 2, + onBannerClose: vi.fn() + }); + await expect.element(page.getByRole('status')).toBeInTheDocument(); + await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument(); + }); +}); -- 2.49.1 From 90c9ca8708d5e406538a606ab2ae6bfe211d6be3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 22:01:46 +0200 Subject: [PATCH 12/19] feat(dropzone): emit onUploadComplete callback with created count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional callback lets the parent route pop a post-upload banner without lifting state into a store. Dashboard uses it to drive UploadSuccessBanner (issue #296). Only fires when the server actually created new documents — duplicates and errors do not trigger it. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/routes/DropZone.svelte | 7 ++ frontend/src/routes/DropZone.svelte.spec.ts | 84 +++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 frontend/src/routes/DropZone.svelte.spec.ts diff --git a/frontend/src/routes/DropZone.svelte b/frontend/src/routes/DropZone.svelte index dd9c5bb6..6f43bee7 100644 --- a/frontend/src/routes/DropZone.svelte +++ b/frontend/src/routes/DropZone.svelte @@ -5,6 +5,12 @@ import { getErrorMessage } from '$lib/errors'; const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff']; +interface Props { + onUploadComplete?: (count: number) => void; +} + +let { onUploadComplete }: Props = $props(); + let isDragging = $state(false); let windowDragging = $state(false); let dragCounter = 0; @@ -80,6 +86,7 @@ async function uploadFiles(files: File[]) { const result = JSON.parse(body); if (result.created?.length > 0) { messages.push({ text: m.upload_success({ count: result.created.length }), isError: false }); + onUploadComplete?.(result.created.length); } for (const doc of result.updated ?? []) { messages.push({ diff --git a/frontend/src/routes/DropZone.svelte.spec.ts b/frontend/src/routes/DropZone.svelte.spec.ts new file mode 100644 index 00000000..514f1b74 --- /dev/null +++ b/frontend/src/routes/DropZone.svelte.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import DropZone from './DropZone.svelte'; + +vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn(async () => {}) })); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function stubXhrWith(responseBody: string) { + class FakeXhr { + upload = { addEventListener: vi.fn() }; + status = 200; + responseText = responseBody; + private loadHandler: (() => void) | null = null; + open = vi.fn(); + addEventListener = vi.fn((event: string, handler: () => void) => { + if (event === 'load') this.loadHandler = handler; + }); + send = vi.fn(() => { + queueMicrotask(() => this.loadHandler?.()); + }); + } + vi.stubGlobal('XMLHttpRequest', FakeXhr); +} + +describe('DropZone onUploadComplete', () => { + it('invokes callback with created.length after a successful upload', async () => { + stubXhrWith(JSON.stringify({ created: [{ id: 'd1' }, { id: 'd2' }], updated: [], errors: [] })); + + const onUploadComplete = vi.fn(); + render(DropZone, { onUploadComplete }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + const file = new File(['%PDF-1.4'], 'test.pdf', { type: 'application/pdf' }); + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + input!.files = dataTransfer.files; + input!.dispatchEvent(new Event('change', { bubbles: true })); + + await vi.waitFor(() => { + expect(onUploadComplete).toHaveBeenCalledTimes(1); + }); + expect(onUploadComplete).toHaveBeenCalledWith(2); + }); + + it('does not invoke callback when no files were created', async () => { + stubXhrWith(JSON.stringify({ created: [], updated: [], errors: [] })); + + const onUploadComplete = vi.fn(); + render(DropZone, { onUploadComplete }); + + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['%PDF-1.4'], 'dupe.pdf', { type: 'application/pdf' }); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + + // Wait a tick to let the microtask flush + await new Promise((r) => setTimeout(r, 50)); + expect(onUploadComplete).not.toHaveBeenCalled(); + }); + + it('works when the onUploadComplete prop is not supplied', async () => { + stubXhrWith(JSON.stringify({ created: [{ id: 'x' }], updated: [], errors: [] })); + + render(DropZone, {}); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['%PDF-1.4'], 'x.pdf', { type: 'application/pdf' }); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + // Should not throw + input.dispatchEvent(new Event('change', { bubbles: true })); + await new Promise((r) => setTimeout(r, 50)); + await expect.element(page.getByText(/1 Dokument/)).toBeInTheDocument(); + }); +}); -- 2.49.1 From f548128940328e084578ef4bd0235ccc7d59e594 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 22:08:09 +0200 Subject: [PATCH 13/19] feat(dashboard): wire EnrichmentBlock between Resume strip and Mission Control Dashboard loader fetches /api/documents/incomplete?size=5 plus the existing /incomplete-count and surfaces both via data; +page.svelte renders EnrichmentBlock with the top 5 docs, the total count, and the bannerCount state bound to DropZone's onUploadComplete callback (issue #296). The block returns null when there is nothing to show, so dashboards without pending uploads stay uncluttered. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/routes/+page.server.ts | 21 +++++++++++++++++++-- frontend/src/routes/+page.svelte | 12 +++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index c89e3ec2..e12e0d89 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -8,6 +8,7 @@ type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklySta type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; type DashboardPulseDTO = components['schemas']['DashboardPulseDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; +type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; export async function load({ fetch }) { const api = createApiClient(fetch); @@ -27,7 +28,9 @@ export async function load({ fetch }) { segmentationResult, transcriptionResult, readyResult, - weeklyStatsResult + weeklyStatsResult, + incompleteResult, + incompleteCountResult ] = await Promise.allSettled([ api.GET('/api/stats'), api.GET('/api/dashboard/resume'), @@ -36,7 +39,9 @@ export async function load({ fetch }) { api.GET('/api/transcription/segmentation-queue'), api.GET('/api/transcription/transcription-queue'), api.GET('/api/transcription/ready-to-read'), - api.GET('/api/transcription/weekly-stats') + api.GET('/api/transcription/weekly-stats'), + api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }), + api.GET('/api/documents/incomplete-count') ]); let stats: StatsDTO | null = null; @@ -47,6 +52,8 @@ export async function load({ fetch }) { let transcriptionDocs: TranscriptionQueueItemDTO[] = []; let readyDocs: TranscriptionQueueItemDTO[] = []; let weeklyStats: TranscriptionWeeklyStatsDTO | null = null; + let incompleteDocs: IncompleteDocumentDTO[] = []; + let incompleteTotal = 0; if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) { stats = statsResult.value.data ?? null; @@ -72,6 +79,12 @@ export async function load({ fetch }) { if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) { weeklyStats = weeklyStatsResult.value.data ?? null; } + if (incompleteResult.status === 'fulfilled' && incompleteResult.value.response.ok) { + incompleteDocs = (incompleteResult.value.data ?? []) as IncompleteDocumentDTO[]; + } + if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) { + incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0; + } return { stats, @@ -82,6 +95,8 @@ export async function load({ fetch }) { transcriptionDocs, readyDocs, weeklyStats, + incompleteDocs, + incompleteTotal, error: null as string | null }; } catch (e) { @@ -96,6 +111,8 @@ export async function load({ fetch }) { transcriptionDocs: [], readyDocs: [], weeklyStats: null, + incompleteDocs: [] as IncompleteDocumentDTO[], + incompleteTotal: 0, error: 'Daten konnten nicht geladen werden.' as string | null }; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 300313ce..2f1f6f5f 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -4,10 +4,13 @@ import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte'; import MissionControlStrip from '$lib/components/MissionControlStrip.svelte'; import DashboardFamilyPulse from '$lib/components/DashboardFamilyPulse.svelte'; import DashboardActivityFeed from '$lib/components/DashboardActivityFeed.svelte'; +import EnrichmentBlock from '$lib/components/EnrichmentBlock.svelte'; import { m } from '$lib/paraglide/messages.js'; let { data } = $props(); +let bannerCount = $state(0); + const greetingText = $derived.by(() => { const name = data?.user?.firstName ?? ''; const h = new Date().getHours(); @@ -32,6 +35,13 @@ const greetingText = $derived.by(() => {
+ (bannerCount = 0)} + /> +

{m.dashboard_mission_caption()} @@ -49,7 +59,7 @@ const greetingText = $derived.by(() => { {#if data.canWrite} - + (bannerCount = count)} /> {/if}

-- 2.49.1 From 3eda4820000ce1f272686765e49c9046db8d9c7f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 22:13:37 +0200 Subject: [PATCH 14/19] =?UTF-8?q?test(e2e):=20dashboard=20enrichment=20blo?= =?UTF-8?q?ck=20=E2=80=94=20upload=20+=20axe=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Happy-path journey (upload 2 PDFs → banner → CTA → /enrich) plus axe sweep at 320/768/1440 × light/dark for the dashboard route. Seeded docs are cleaned up in afterEach via psql so repeated runs stay green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../e2e/dashboard-enrichment-block.spec.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 frontend/e2e/dashboard-enrichment-block.spec.ts diff --git a/frontend/e2e/dashboard-enrichment-block.spec.ts b/frontend/e2e/dashboard-enrichment-block.spec.ts new file mode 100644 index 00000000..28aa4141 --- /dev/null +++ b/frontend/e2e/dashboard-enrichment-block.spec.ts @@ -0,0 +1,83 @@ +/** + * Dashboard enrichment-list-block (issue #296) — full upload → banner → CTA journey, + * plus axe sweep in light and dark mode at 320 / 768 / 1440 viewports. + * + * The uploaded PDFs are deleted in afterEach so this spec does not pollute + * the dev DB between runs. + */ +import AxeBuilder from '@axe-core/playwright'; +import { test, expect, type Page } from '@playwright/test'; +import { execSync } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const VIEWPORTS = [ + { name: '320', width: 320, height: 720 }, + { name: '768', width: 768, height: 1024 }, + { name: '1440', width: 1440, height: 900 } +]; + +const psql = (sql: string) => + execSync( + `docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"` + ); + +test.afterEach(() => { + // Remove any document whose filename matches the seeded sentinel — keeps the + // DB clean for subsequent test runs. + psql(`DELETE FROM documents WHERE original_filename IN ('minimal.pdf', 'minimal2.pdf');`); +}); + +async function uploadViaDropZone(page: Page, files: string[]) { + const inputLocator = page.locator('input[type="file"][accept*="pdf"]'); + await inputLocator.first().setInputFiles(files); +} + +test.describe('Dashboard enrichment block — upload → banner → CTA', () => { + test('banner appears after upload and CTA navigates to /enrich', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + const fixturePath = (name: string) => path.join(__dirname, 'fixtures', name); + await uploadViaDropZone(page, [fixturePath('minimal.pdf'), fixturePath('minimal2.pdf')]); + + const banner = page.getByRole('status').filter({ hasText: /hochgeladen/ }); + await expect(banner).toBeVisible(); + await expect(banner).toContainText(/2 Dokumente/); + + await banner.getByRole('link', { name: /ergänzen/i }).click(); + await expect(page).toHaveURL(/\/enrich(\/|$)/); + }); +}); + +test.describe('Dashboard enrichment block — axe sweep', () => { + for (const viewport of VIEWPORTS) { + for (const scheme of ['light', 'dark'] as const) { + test(`no wcag2a/wcag2aa violations at ${viewport.name}px (${scheme})`, async ({ + browser + }) => { + const context = await browser.newContext({ + colorScheme: scheme, + viewport: { width: viewport.width, height: viewport.height }, + storageState: path.join(__dirname, '.auth/user.json') + }); + const page = await context.newPage(); + await page.goto('/'); + await page.waitForSelector('[data-hydrated]'); + + const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + + if (results.violations.length > 0) { + console.log( + `Violations on dashboard @ ${viewport.name}px ${scheme}:\n` + + results.violations.map((v) => `[${v.impact}] ${v.id}`).join('\n') + ); + } + expect(results.violations).toEqual([]); + await context.close(); + }); + } + } +}); -- 2.49.1 From d3f9f8457a23c9cfbc143b9e6fb6d4e304d7a7a2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 22:26:31 +0200 Subject: [PATCH 15/19] test(dashboard): extend page.server mock chain for incomplete endpoints The two "happy path" dashboard load tests now mock the two additional calls added in f5481289 (/api/documents/incomplete + incomplete-count) so the Promise.allSettled array resolves fully. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/routes/page.server.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 68791aff..06caaf9a 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -92,7 +92,9 @@ describe('home page load — dashboard', () => { .mockResolvedValueOnce({ response: { ok: true }, data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 } - }); // weekly-stats + }) // weekly-stats + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete + .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< typeof createApiClient >); @@ -123,7 +125,9 @@ describe('home page load — dashboard', () => { .mockResolvedValueOnce({ response: { ok: true }, data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 } - }); // weekly-stats + }) // weekly-stats + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete + .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< typeof createApiClient >); -- 2.49.1 From 30ea1f0dcf77d1bce269eec39553c776ab7bd72b Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 22:36:41 +0200 Subject: [PATCH 16/19] test(dashboard): exercise the EnrichmentBlock skeleton branch Hoists the $navigating store into a shared __mocks__ module so tests can drive it through real transitions. Adds two specs covering (a) skeleton visible while $navigating && topDocs empty and (b) skeleton hidden when topDocs is non-empty. Also sets aria-busy="true" on the skeleton so screen readers announce the loading state (Leonie's a11y suggestion). Addresses Sara's and Felix's review concern that the skeleton branch was dead code in the test world. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/components/EnrichmentBlock.svelte | 4 ++- .../components/EnrichmentBlock.svelte.spec.ts | 34 +++++++++++++++++-- .../components/__mocks__/navigatingStore.ts | 3 ++ 3 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/components/__mocks__/navigatingStore.ts diff --git a/frontend/src/lib/components/EnrichmentBlock.svelte b/frontend/src/lib/components/EnrichmentBlock.svelte index 2743329f..a4327cf3 100644 --- a/frontend/src/lib/components/EnrichmentBlock.svelte +++ b/frontend/src/lib/components/EnrichmentBlock.svelte @@ -31,8 +31,10 @@ const showBlock = $derived(topDocs.length > 0 || bannerCount > 0 || showSkeleton {:else if showSkeleton} {/if} diff --git a/frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts b/frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts index be187cf5..ac5167d1 100644 --- a/frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts +++ b/frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts @@ -2,14 +2,20 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +// The store must live in a separate module because vi.mock factories are +// hoisted and cannot reference top-level variables defined in this file. +import { navigatingStore } from './__mocks__/navigatingStore'; import EnrichmentBlock from './EnrichmentBlock.svelte'; vi.mock('$app/stores', async () => { - const { writable } = await import('svelte/store'); - return { navigating: writable(null) }; + const mod = await import('./__mocks__/navigatingStore'); + return { navigating: mod.navigatingStore }; }); -afterEach(cleanup); +afterEach(() => { + cleanup(); + navigatingStore.set(null); +}); type Doc = { id: string; title: string; uploadedAt: string }; @@ -58,4 +64,26 @@ describe('EnrichmentBlock', () => { await expect.element(page.getByRole('status')).toBeInTheDocument(); await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument(); }); + + it('renders the skeleton when $navigating is active and topDocs is empty', async () => { + navigatingStore.set({ type: 'link' }); + render(EnrichmentBlock, { + topDocs: [], + totalCount: 0, + bannerCount: 0, + onBannerClose: vi.fn() + }); + await expect.element(page.getByTestId('enrichment-block-skeleton')).toBeInTheDocument(); + }); + + it('does not render the skeleton when topDocs is non-empty even during $navigating', async () => { + navigatingStore.set({ type: 'link' }); + render(EnrichmentBlock, { + topDocs: [doc('d1')], + totalCount: 1, + bannerCount: 0, + onBannerClose: vi.fn() + }); + await expect.element(page.getByTestId('enrichment-block-skeleton')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/lib/components/__mocks__/navigatingStore.ts b/frontend/src/lib/components/__mocks__/navigatingStore.ts new file mode 100644 index 00000000..932122e4 --- /dev/null +++ b/frontend/src/lib/components/__mocks__/navigatingStore.ts @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; + +export const navigatingStore = writable(null); -- 2.49.1 From 97e8e4fc7412eb73e49411185abdf7bb6b527a7c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 22:42:23 +0200 Subject: [PATCH 17/19] test(dropzone): replace setTimeout flake with vi.waitFor + hoisted mock The "no-callback" and "no-prop" tests no longer rely on an arbitrary 50ms sleep. Test 2 awaits the mocked invalidateAll call (the last async step of the upload handler) before asserting the callback was not invoked. Test 3 lets vitest-browser-svelte's own expect.element poll until the success message appears. Addresses Sara's and Felix's review concern about flake-prone timing. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/routes/DropZone.svelte.spec.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/DropZone.svelte.spec.ts b/frontend/src/routes/DropZone.svelte.spec.ts index 514f1b74..440c2f0c 100644 --- a/frontend/src/routes/DropZone.svelte.spec.ts +++ b/frontend/src/routes/DropZone.svelte.spec.ts @@ -4,7 +4,10 @@ import { page } from 'vitest/browser'; import DropZone from './DropZone.svelte'; -vi.mock('$app/navigation', () => ({ invalidateAll: vi.fn(async () => {}) })); +// vi.hoisted lets the mock fn reference survive vi.mock's hoisting so tests +// can assert on it from below while the factory remains self-contained. +const { invalidateAllMock } = vi.hoisted(() => ({ invalidateAllMock: vi.fn(async () => {}) })); +vi.mock('$app/navigation', () => ({ invalidateAll: invalidateAllMock })); afterEach(() => { cleanup(); @@ -62,8 +65,11 @@ describe('DropZone onUploadComplete', () => { input.files = dt.files; input.dispatchEvent(new Event('change', { bubbles: true })); - // Wait a tick to let the microtask flush - await new Promise((r) => setTimeout(r, 50)); + // invalidateAll is the last async step of the upload handler — once it + // has been called, the callback decision has already been made. + await vi.waitFor(() => { + expect(invalidateAllMock).toHaveBeenCalled(); + }); expect(onUploadComplete).not.toHaveBeenCalled(); }); @@ -75,10 +81,9 @@ describe('DropZone onUploadComplete', () => { const file = new File(['%PDF-1.4'], 'x.pdf', { type: 'application/pdf' }); const dt = new DataTransfer(); dt.items.add(file); + // Should not throw when the optional callback is absent. input.files = dt.files; - // Should not throw input.dispatchEvent(new Event('change', { bubbles: true })); - await new Promise((r) => setTimeout(r, 50)); await expect.element(page.getByText(/1 Dokument/)).toBeInTheDocument(); }); }); -- 2.49.1 From 35303831f77c69866961bbaaf3eaa3558dde1e87 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 22:44:45 +0200 Subject: [PATCH 18/19] a11y(dashboard): larger dismiss target + motion-reduce + sr-only PDF label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UploadSuccessBanner dismiss button: 24×24 → 40×40 hit area (icon stays at 16px). Matches senior-first baseline Leonie flagged. - DashboardNeedsMetadata chevron: adds motion-reduce:transition-none and motion-reduce:group-hover:translate-x-0 so users with prefers-reduced- motion do not see the hover translate. - Row title prefixed with an sr-only "PDF: " span so assistive tech announces the document affordance alongside the title. Addresses Leonie's review concerns #2, #3, and the sr-only nit. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/components/DashboardNeedsMetadata.svelte | 4 ++-- frontend/src/lib/components/UploadSuccessBanner.svelte | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/components/DashboardNeedsMetadata.svelte b/frontend/src/lib/components/DashboardNeedsMetadata.svelte index d7203a8b..79303308 100644 --- a/frontend/src/lib/components/DashboardNeedsMetadata.svelte +++ b/frontend/src/lib/components/DashboardNeedsMetadata.svelte @@ -38,14 +38,14 @@ const showFooter = $derived(totalCount > 5); />
- {doc.title} + PDF: {doc.title}
{relativeTimeDe(new Date(doc.uploadedAt))}
{ data-testid="upload-banner-close" aria-label={m.upload_banner_close()} onclick={onClose} - class="inline-flex h-6 w-6 items-center justify-center rounded-sm text-ink-3 hover:bg-ink/10 hover:text-ink" + class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-sm text-ink-3 hover:bg-ink/10 hover:text-ink" > Date: Mon, 20 Apr 2026 22:46:53 +0200 Subject: [PATCH 19/19] fix(relativeTime): guard against Invalid Date producing NaN strings If a row ever receives a malformed uploadedAt (e.g. manual SQL migration, backend regression), the helper now falls back to "vor 0 Minute(n)" rather than rendering "vor NaN Tag(en)" to the user. Addresses Nora's review suggestion. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/lib/relativeTime.spec.ts | 7 +++++++ frontend/src/lib/relativeTime.ts | 3 +++ 2 files changed, 10 insertions(+) diff --git a/frontend/src/lib/relativeTime.spec.ts b/frontend/src/lib/relativeTime.spec.ts index 9373986e..1855d07d 100644 --- a/frontend/src/lib/relativeTime.spec.ts +++ b/frontend/src/lib/relativeTime.spec.ts @@ -31,4 +31,11 @@ describe('relativeTimeDe', () => { expect(relativeTimeDe(NOW, NOW)).toMatch(/0/); expect(relativeTimeDe(NOW, NOW)).toMatch(/Minute/i); }); + + it('falls back to 0 minutes when the input Date is invalid', () => { + const invalid = new Date('not-a-real-date'); + // Never crash the UI if the backend ever ships a malformed uploadedAt. + expect(relativeTimeDe(invalid, NOW)).toMatch(/0/); + expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i); + }); }); diff --git a/frontend/src/lib/relativeTime.ts b/frontend/src/lib/relativeTime.ts index 1c78367a..f2e45a7e 100644 --- a/frontend/src/lib/relativeTime.ts +++ b/frontend/src/lib/relativeTime.ts @@ -2,6 +2,9 @@ import * as m from '$lib/paraglide/messages.js'; export function relativeTimeDe(from: Date, now: Date = new Date()): string { const minutes = Math.round((now.getTime() - from.getTime()) / 60_000); + // Malformed input (e.g. Invalid Date from a broken backend string) must not + // crash the dashboard — fall back to "0 Minuten" rather than render NaN. + if (!Number.isFinite(minutes)) return m.comment_time_minutes({ count: 0 }); if (minutes < 60) return m.comment_time_minutes({ count: minutes }); if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) }); return m.comment_time_days({ count: Math.round(minutes / 1440) }); -- 2.49.1