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..e5f0bb17 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; @@ -193,11 +194,21 @@ public class DocumentController { } @GetMapping("/incomplete-count") + @RequirePermission(Permission.WRITE_ALL) public Map getIncompleteCount() { return Map.of("count", documentService.getIncompleteCount()); } + @GetMapping("/incomplete") + @RequirePermission(Permission.WRITE_ALL) + 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") + @RequirePermission(Permission.WRITE_ALL) public ResponseEntity getNextIncomplete(@RequestParam UUID excludeId) { return documentService.findNextIncompleteDocument(excludeId) .map(ResponseEntity::ok) 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/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 6b8b5fbc..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,14 +391,52 @@ class DocumentControllerTest { .andExpect(jsonPath("$.count").value(3)); } - // ─── GET /api/documents/incomplete (removed — superseded by dashboard) ──── + @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 - @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()); + } + + @Test + @WithMockUser(authorities = "READ_ALL") + void getIncomplete_returns403_forReaderOnly() throws Exception { + mockMvc.perform(get("/api/documents/incomplete")) + .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 ────────────────────────────────── @@ -411,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() @@ -425,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()); 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))) 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(); + }); + } + } +}); 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", diff --git a/frontend/src/lib/components/DashboardNeedsMetadata.svelte b/frontend/src/lib/components/DashboardNeedsMetadata.svelte index cb197418..79303308 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(); }); }); diff --git a/frontend/src/lib/components/EnrichmentBlock.svelte b/frontend/src/lib/components/EnrichmentBlock.svelte new file mode 100644 index 00000000..a4327cf3 --- /dev/null +++ b/frontend/src/lib/components/EnrichmentBlock.svelte @@ -0,0 +1,41 @@ + + +{#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..ac5167d1 --- /dev/null +++ b/frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts @@ -0,0 +1,89 @@ +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 mod = await import('./__mocks__/navigatingStore'); + return { navigating: mod.navigatingStore }; +}); + +afterEach(() => { + cleanup(); + navigatingStore.set(null); +}); + +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(); + }); + + 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/UploadSuccessBanner.svelte b/frontend/src/lib/components/UploadSuccessBanner.svelte new file mode 100644 index 00000000..a470ee0c --- /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); + }); +}); 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); 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: { diff --git a/frontend/src/lib/relativeTime.spec.ts b/frontend/src/lib/relativeTime.spec.ts new file mode 100644 index 00000000..1855d07d --- /dev/null +++ b/frontend/src/lib/relativeTime.spec.ts @@ -0,0 +1,41 @@ +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); + }); + + 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 new file mode 100644 index 00000000..f2e45a7e --- /dev/null +++ b/frontend/src/lib/relativeTime.ts @@ -0,0 +1,11 @@ +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) }); +} 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}

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..440c2f0c --- /dev/null +++ b/frontend/src/routes/DropZone.svelte.spec.ts @@ -0,0 +1,89 @@ +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.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(); + 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 })); + + // 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(); + }); + + 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); + // Should not throw when the optional callback is absent. + input.files = dt.files; + input.dispatchEvent(new Event('change', { bubbles: true })); + await expect.element(page.getByText(/1 Dokument/)).toBeInTheDocument(); + }); +}); 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 >);