feat: dashboard enrichment-list-block after batch upload (#296) #298

Merged
marcel merged 19 commits from feat/issue-296-enrichment-list-block into main 2026-04-21 08:59:32 +02:00
24 changed files with 753 additions and 59 deletions

View File

@@ -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<String, Long> getIncompleteCount() {
return Map.of("count", documentService.getIncompleteCount());
}
@GetMapping("/incomplete")
@RequirePermission(Permission.WRITE_ALL)
public List<IncompleteDocumentDTO> 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<Document> getNextIncomplete(@RequestParam UUID excludeId) {
return documentService.findNextIncompleteDocument(excludeId)
.map(ResponseEntity::ok)

View File

@@ -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
) {}

View File

@@ -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();
}

View File

@@ -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());

View File

@@ -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<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
assertThat(result.get(0).uploadedAt()).isEqualTo(createdAt);
}
@Test
void findIncompleteDocuments_passesSizeToPageable() {
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))

View File

@@ -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();
});
}
}
});

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1,37 +1,73 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/relativeTime';
type IncompleteDocumentDTO = {
type IncompleteDoc = {
id: string;
title: string;
uploadedAt: string;
};
interface Props {
incompleteDocs: IncompleteDocumentDTO[];
topDocs: IncompleteDoc[];
totalCount: number;
}
let { incompleteDocs }: Props = $props();
let { topDocs, totalCount }: Props = $props();
const showFooter = $derived(totalCount > 5);
</script>
{#if incompleteDocs.length > 0}
{#if topDocs.length > 0}
<div data-testid="dashboard-needs-metadata" class="rounded-sm border border-line bg-surface p-6">
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.dashboard_needs_metadata_heading()}
</h2>
{#each incompleteDocs as doc (doc.id)}
<div class="flex items-center border-b border-line py-2 last:border-0">
<ul class="divide-y divide-line">
{#each topDocs as doc (doc.id)}
<li>
<a
href="/enrich/{doc.id}"
class="group flex items-center gap-3 py-3 text-ink hover:bg-accent-bg/40"
>
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Copy-Item-MD.svg"
alt=""
aria-hidden="true"
class="h-5 w-5 shrink-0 opacity-50 group-hover:opacity-80"
/>
<div class="min-w-0 flex-1">
<div class="truncate font-serif text-base text-ink group-hover:underline">
<span class="sr-only">PDF: </span>{doc.title}
</div>
<div class="font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(doc.uploadedAt))}
</div>
</div>
<svg
class="h-4 w-4 shrink-0 text-ink-3 transition-transform group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</a>
</li>
{/each}
</ul>
{#if showFooter}
<div class="mt-4">
<a
href="/enrich/{doc.id}"
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
href="/enrich"
class="font-sans text-sm font-medium text-ink-2 hover:text-ink hover:underline"
>
{doc.title}
{m.dashboard_needs_metadata_show_all_count({ count: totalCount })}
</a>
</div>
{/each}
<div class="mt-4">
<a href="/enrich" class="font-sans text-sm text-ink-2 hover:text-ink hover:underline"
>{m.dashboard_needs_metadata_show_all()}</a
>
</div>
{/if}
</div>
{/if}

View File

@@ -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();
});
});

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { navigating } from '$app/stores';
import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
import UploadSuccessBanner from './UploadSuccessBanner.svelte';
type IncompleteDoc = {
id: string;
title: string;
uploadedAt: string;
};
interface Props {
topDocs: IncompleteDoc[];
totalCount: number;
bannerCount: number;
onBannerClose: () => void;
}
let { topDocs, totalCount, bannerCount, onBannerClose }: Props = $props();
const showSkeleton = $derived(!!$navigating && topDocs.length === 0);
const showBlock = $derived(topDocs.length > 0 || bannerCount > 0 || showSkeleton);
</script>
{#if showBlock}
<div data-testid="enrichment-block" class="flex flex-col gap-3">
{#if bannerCount > 0}
<UploadSuccessBanner count={bannerCount} onClose={onBannerClose} />
{/if}
{#if topDocs.length > 0}
<DashboardNeedsMetadata topDocs={topDocs} totalCount={totalCount} />
{:else if showSkeleton}
<div
data-testid="enrichment-block-skeleton"
class="h-[360px] animate-pulse rounded-sm border border-line bg-surface/50 motion-reduce:animate-none"
aria-busy="true"
aria-label="Lade aktualisierte Liste"
></div>
{/if}
</div>
{/if}

View File

@@ -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();
});
});

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
count: number;
onClose: () => void;
}
let { count, onClose }: Props = $props();
const message = $derived(
count === 1 ? m.upload_banner_singular() : m.upload_banner_plural({ count })
);
$effect(() => {
const timer = setTimeout(onClose, 8000);
return () => clearTimeout(timer);
});
</script>
<div
role="status"
aria-live="polite"
class="flex items-center gap-3 rounded-sm border border-line bg-accent-bg/60 px-4 py-3 text-ink"
>
<svg
class="h-5 w-5 shrink-0 text-primary"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p class="flex-1 font-sans text-sm">
<span>{message}</span>
<a href="/enrich" class="ml-1 font-medium text-primary hover:underline">
{m.upload_banner_cta()}
</a>
</p>
<button
type="button"
data-testid="upload-banner-close"
aria-label={m.upload_banner_close()}
onclick={onClose}
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"
>
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>

View File

@@ -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);
});
});

View File

@@ -0,0 +1,3 @@
import { writable } from 'svelte/store';
export const navigatingStore = writable<unknown | null>(null);

View File

@@ -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: {

View File

@@ -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);
});
});

View File

@@ -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) });
}

View File

@@ -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
};
}

View File

@@ -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(() => {
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<EnrichmentBlock
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
/>
<section aria-label={m.dashboard_mission_caption()}>
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_mission_caption()}
@@ -49,7 +59,7 @@ const greetingText = $derived.by(() => {
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone />
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if}
</div>
</div>

View File

@@ -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({

View File

@@ -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();
});
});

View File

@@ -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
>);