From a1035171c2c185c70a2957836a60fa4587bcf4bb Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 14:31:55 +0200 Subject: [PATCH 01/17] fix(reader-dashboard): recentDocs items were always undefined for READ_ALL users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server mapped DocumentSearchResult items as { document: Document }[] but the API returns flat DocumentListItem[] — so i.document was always undefined, crashing the reader homepage with a 500. Fix the type + mapping in +page.server.ts, add createdAt/updatedAt to DocumentListItem (needed by ReaderRecentDocs for relative-time display), and update the component to accept DocumentListItem instead of Document. Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentListItem.java | 7 ++- .../document/DocumentService.java | 4 +- .../document/DocumentControllerTest.java | 4 +- .../document/DocumentSearchResultTest.java | 4 +- frontend/src/lib/generated/api.ts | 4 ++ .../shared/dashboard/ReaderRecentDocs.svelte | 6 +-- frontend/src/routes/+page.server.ts | 8 ++-- frontend/src/routes/page.server.spec.ts | 45 +++++++++++++++++++ 8 files changed, 69 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java index 7cbc6496..bf8c19a3 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentListItem.java @@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.tag.Tag; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -32,5 +33,9 @@ public record DocumentListItem( @Schema(requiredMode = Schema.RequiredMode.REQUIRED) List contributors, @Schema(requiredMode = Schema.RequiredMode.REQUIRED) - SearchMatchData matchData + SearchMatchData matchData, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime createdAt, + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime updatedAt ) {} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index cfbbf848..65bd7cd7 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -767,7 +767,9 @@ public class DocumentService { doc.getSummary(), completionPct, contributors, - match + match, + doc.getCreatedAt(), + doc.getUpdatedAt() ); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index f7c24541..359211ba 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -135,7 +135,7 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData)))); + 0, List.of(), matchData, null, null)))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) .andExpect(status().isOk()) @@ -153,7 +153,7 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData)))); + 0, List.of(), matchData, null, null)))); mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java index 1dd09fed..9effa3bc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java @@ -16,7 +16,7 @@ class DocumentSearchResultTest { return new DocumentListItem( docId, "Test", "test.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), SearchMatchData.empty()); + 0, List.of(), SearchMatchData.empty(), null, null); } @Test @@ -66,7 +66,7 @@ class DocumentSearchResultTest { DocumentListItem item = new DocumentListItem( id, "T", "t.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 75, List.of(actor), SearchMatchData.empty()); + 75, List.of(actor), SearchMatchData.empty(), null, null); DocumentSearchResult result = DocumentSearchResult.of(List.of(item)); diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9a9a5408..c2888d97 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2407,6 +2407,10 @@ export interface components { completionPercentage: number; contributors: components["schemas"]["ActivityActorDTO"][]; matchData: components["schemas"]["SearchMatchData"]; + /** Format: date-time */ + createdAt: string; + /** Format: date-time */ + updatedAt: string; }; DocumentSearchResult: { items: components["schemas"]["DocumentListItem"][]; diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte index 9f54d6b5..5721accc 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -3,15 +3,15 @@ import * as m from '$lib/paraglide/messages.js'; import { relativeTimeDe } from '$lib/shared/relativeTime'; import type { components } from '$lib/generated/api'; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; interface Props { - documents: Document[]; + documents: DocumentListItem[]; } const { documents }: Props = $props(); -function isNew(doc: Document): boolean { +function isNew(doc: DocumentListItem): boolean { return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime(); } diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index b217452f..ed229042 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -10,7 +10,7 @@ type DashboardPulseDTO = components['schemas']['DashboardPulseDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; type Geschichte = components['schemas']['Geschichte']; function settled(res: PromiseSettledResult | undefined): T | null { @@ -53,8 +53,8 @@ export async function load({ fetch, parent }) { const readerStats = settled(statsRes); const topPersons = settled(topPersonsRes) ?? []; - const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); - const recentDocs = searchData?.items.map((i) => i.document) ?? []; + const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes); + const recentDocs = searchData?.items ?? []; const recentStories = settled(recentStoriesRes) ?? []; const drafts = settled(draftsRes) ?? []; @@ -167,7 +167,7 @@ export async function load({ fetch, parent }) { incompleteTotal: 0, readerStats: null, topPersons: [] as PersonSummaryDTO[], - recentDocs: [] as Document[], + recentDocs: [] as DocumentListItem[], recentStories: [] as Geschichte[], drafts: [] as Geschichte[], error: 'Daten konnten nicht geladen werden.' as string | null diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index b87ce9a8..27ce1e30 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -394,6 +394,51 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate expect(result.isReader).toBe(false); }); + it('maps search result items directly to recentDocs without wrapping in a .document property', async () => { + const searchItem = { + id: 'd1', + title: 'Liebesbrief', + originalFilename: 'letter.pdf', + completionPercentage: 80, + receivers: [], + tags: [], + contributors: [], + matchData: { titleOffsets: [], senderMatched: false } + }; + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // initial persons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { totalDocuments: 1, totalPersons: 1 } + }) // stats + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // topPersons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 } + }) // search + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // stories + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + request: new Request('http://localhost/'), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(true); + if (result.isReader) { + expect(result.recentDocs).toHaveLength(1); + expect(result.recentDocs[0]).toBeDefined(); + expect(result.recentDocs[0].id).toBe('d1'); + } + }); + it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => { const okStats = { response: { ok: true, status: 200 }, From 2e0f85c3606b3d0e2f6d732d6564aa0136adc386 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 15:08:04 +0200 Subject: [PATCH 02/17] fix(review): address reviewer concerns from PR #661 - Replace brittle createdAt===updatedAt isNew() check with a 7-day recency window (created within last 7 days = new) - Add createdAt/updatedAt to searchItem fixture in page.server.spec.ts and assert they are propagated to recentDocs - Replace null timestamps in DocumentListItem test fixtures with a fixed LocalDateTime to satisfy the @Schema(required) contract Co-Authored-By: Claude Sonnet 4.6 --- .../document/DocumentControllerTest.java | 6 +++-- .../document/DocumentSearchResultTest.java | 7 ++++-- .../shared/dashboard/ReaderRecentDocs.svelte | 2 +- .../dashboard/ReaderRecentDocs.svelte.spec.ts | 24 ++++++++++++------- .../dashboard/ReaderRecentDocs.svelte.test.ts | 12 +++++----- frontend/src/routes/page.server.spec.ts | 6 ++++- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java index 359211ba..fe15ba3b 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentControllerTest.java @@ -135,7 +135,8 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData, null, null)))); + 0, List.of(), matchData, + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0))))); mockMvc.perform(get("/api/documents/search").param("q", "Brief")) .andExpect(status().isOk()) @@ -153,7 +154,8 @@ class DocumentControllerTest { .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( docId, "Brief an Anna", "brief.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), matchData, null, null)))); + 0, List.of(), matchData, + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0))))); mockMvc.perform(get("/api/documents/search")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java index 9effa3bc..a487e272 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSearchResultTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.springframework.data.domain.PageRequest; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -16,7 +17,8 @@ class DocumentSearchResultTest { return new DocumentListItem( docId, "Test", "test.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 0, List.of(), SearchMatchData.empty(), null, null); + 0, List.of(), SearchMatchData.empty(), + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)); } @Test @@ -66,7 +68,8 @@ class DocumentSearchResultTest { DocumentListItem item = new DocumentListItem( id, "T", "t.pdf", null, null, null, List.of(), List.of(), null, null, null, null, - 75, List.of(actor), SearchMatchData.empty(), null, null); + 75, List.of(actor), SearchMatchData.empty(), + LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)); DocumentSearchResult result = DocumentSearchResult.of(List.of(item)); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte index 5721accc..98985f95 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -12,7 +12,7 @@ interface Props { const { documents }: Props = $props(); function isNew(doc: DocumentListItem): boolean { - return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime(); + return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000; } diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts index c13c92b1..51dd5d8e 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -88,8 +88,14 @@ describe('ReaderRecentDocs', () => { expect(thumb!.className).toMatch(/rounded-/); }); - it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => { - render(ReaderRecentDocs, { documents: [baseDoc] }); + it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => { + const recentDoc: Document = { + ...baseDoc, + id: 'doc-recent', + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString() + }; + render(ReaderRecentDocs, { documents: [recentDoc] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).toBeInTheDocument(); const cls = ((await badge.element()) as HTMLElement).className; @@ -98,7 +104,7 @@ describe('ReaderRecentDocs', () => { expect(cls).toMatch(/\btext-ink\b/); }); - it('shows no badge when updatedAt differs from createdAt', async () => { + it('shows no badge when document was created more than 7 days ago', async () => { render(ReaderRecentDocs, { documents: [updatedDoc] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).not.toBeInTheDocument(); @@ -106,14 +112,14 @@ describe('ReaderRecentDocs', () => { await expect.element(updatedBadge).not.toBeInTheDocument(); }); - it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => { - const sameInstantDoc: Document = { + it('shows "Neu" badge when document was created 6 days ago', async () => { + const almostOldDoc: Document = { ...baseDoc, - id: 'doc-same-instant', - createdAt: '2025-01-01T12:00:00Z', - updatedAt: '2025-01-01T12:00:00.000Z' + id: 'doc-almost-old', + createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), + updatedAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString() }; - render(ReaderRecentDocs, { documents: [sameInstantDoc] }); + render(ReaderRecentDocs, { documents: [almostOldDoc] }); const badge = page.getByText(/^Neu$/i); await expect.element(badge).toBeInTheDocument(); }); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts index d15e6f7f..6c93c55e 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.test.ts @@ -31,25 +31,25 @@ describe('ReaderRecentDocs', () => { .toHaveAttribute('href', '/documents'); }); - it('renders the New badge when createdAt equals updatedAt', async () => { + it('renders the New badge when document was created within the last 7 days', async () => { + const recentDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const laterUpdate = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); render(ReaderRecentDocs, { props: { - documents: [ - makeDoc({ createdAt: '2026-04-15T10:00:00Z', updatedAt: '2026-04-15T10:00:00Z' }) - ] + documents: [makeDoc({ createdAt: recentDate, updatedAt: laterUpdate })] } }); await expect.element(page.getByText('Neu')).toBeVisible(); }); - it('hides the New badge when document was updated after creation', async () => { + it('hides the New badge when document was created more than 7 days ago', async () => { render(ReaderRecentDocs, { props: { documents: [ makeDoc({ createdAt: '2026-04-15T10:00:00Z', - updatedAt: '2026-04-15T11:00:00Z' + updatedAt: '2026-04-15T10:00:00Z' }) ] } diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 27ce1e30..a04cf1fe 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -403,7 +403,9 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate receivers: [], tags: [], contributors: [], - matchData: { titleOffsets: [], senderMatched: false } + matchData: { titleOffsets: [], senderMatched: false }, + createdAt: '2026-05-01T10:00:00Z', + updatedAt: '2026-05-10T08:00:00Z' }; const mockGet = vi .fn() @@ -436,6 +438,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate expect(result.recentDocs).toHaveLength(1); expect(result.recentDocs[0]).toBeDefined(); expect(result.recentDocs[0].id).toBe('d1'); + expect(result.recentDocs[0].createdAt).toBe('2026-05-01T10:00:00Z'); + expect(result.recentDocs[0].updatedAt).toBe('2026-05-10T08:00:00Z'); } }); From d9e01ef1ff5712dc40e4e22964921792d2facc2a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:25:46 +0200 Subject: [PATCH 03/17] fix(review): regenerate api.ts and fix spec type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace manual edits to api.ts with a proper `npm run generate:api` run — the generated output is identical for DocumentListItem (createdAt/updatedAt were already correct), so this just removes the drift risk flagged in review. Fix ReaderRecentDocs.svelte.spec.ts to use DocumentListItem instead of Document for all test fixtures, matching the component's actual prop type. Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 10 +++---- frontend/src/lib/generated/api.ts | 6 ++--- .../dashboard/ReaderRecentDocs.svelte.spec.ts | 27 ++++++++++++------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a408615d..9738729a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,9 +23,9 @@ "@eslint/compat": "^1.4.0", "@eslint/js": "^9.39.1", "@inlang/paraglide-js": "^2.5.0", - "@playwright/test": "^1.58.2", - "@sveltejs/adapter-node": "^5.4.0", - "@sveltejs/kit": "^2.48.5", + "@playwright/test": "^1.60.0", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.60.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/forms": "^0.5.10", "@tailwindcss/typography": "^0.5.19", @@ -43,7 +43,7 @@ "globals": "^16.5.0", "openapi-typescript": "^7.8.0", "patch-package": "^8.0.0", - "playwright": "^1.56.1", + "playwright": "^1.60.0", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.7.1", @@ -52,7 +52,7 @@ "tailwindcss": "^4.1.17", "typescript": "^5.9.3", "typescript-eslint": "^8.47.0", - "vite": "^7.2.2", + "vite": "^7.3.3", "vite-plugin-devtools-json": "^1.0.0", "vitest": "^4.0.10", "vitest-browser-svelte": "^2.0.1" diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index c2888d97..62f94fd8 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -2205,10 +2205,10 @@ export interface components { totalStories: number; }; PersonSummaryDTO: { + title?: string; /** Format: uuid */ id?: string; displayName?: string; - title?: string; firstName?: string; lastName?: string; /** Format: int64 */ @@ -2315,8 +2315,6 @@ export interface components { /** Format: int32 */ totalPages?: number; pageable?: components["schemas"]["PageableObject"]; - first?: boolean; - last?: boolean; /** Format: int32 */ size?: number; content?: components["schemas"]["NotificationDTO"][]; @@ -2325,6 +2323,8 @@ export interface components { sort?: components["schemas"]["SortObject"]; /** Format: int32 */ numberOfElements?: number; + first?: boolean; + last?: boolean; empty?: boolean; }; PageableObject: { diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts index 51dd5d8e..7fa8ab9d 100644 --- a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -5,24 +5,33 @@ import { page } from 'vitest/browser'; import ReaderRecentDocs from './ReaderRecentDocs.svelte'; import type { components } from '$lib/generated/api'; -type Document = components['schemas']['Document']; +type DocumentListItem = components['schemas']['DocumentListItem']; afterEach(() => { cleanup(); }); -const baseDoc: Document = { +const baseDoc: DocumentListItem = { id: 'doc1', title: 'Brief an Hans', originalFilename: 'brief.pdf', - status: 'UPLOADED', - metadataComplete: true, - scriptType: 'HANDWRITING_KURRENT', + completionPercentage: 0, + receivers: [], + tags: [], + contributors: [], + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, createdAt: '2025-01-01T12:00:00Z', updatedAt: '2025-01-01T12:00:00Z' }; -const updatedDoc: Document = { +const updatedDoc: DocumentListItem = { ...baseDoc, id: 'doc2', title: 'Urkunde 1920', @@ -89,7 +98,7 @@ describe('ReaderRecentDocs', () => { }); it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => { - const recentDoc: Document = { + const recentDoc: DocumentListItem = { ...baseDoc, id: 'doc-recent', createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), @@ -113,7 +122,7 @@ describe('ReaderRecentDocs', () => { }); it('shows "Neu" badge when document was created 6 days ago', async () => { - const almostOldDoc: Document = { + const almostOldDoc: DocumentListItem = { ...baseDoc, id: 'doc-almost-old', createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), @@ -125,7 +134,7 @@ describe('ReaderRecentDocs', () => { }); it('renders sender name text when sender is present', async () => { - const docWithSender: Document = { + const docWithSender: DocumentListItem = { ...baseDoc, sender: { id: 'p1', From 2e0eb40aec398324f93e538bd6919ea693be3e56 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:40:10 +0200 Subject: [PATCH 04/17] test(debounce): fix flaky onExit-cancels-debounce test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test raced a real 150 ms setTimeout: fill('Walter') started the debounce, then focus + keyboard(Escape) had to complete before 150 ms elapsed. Under CI load the Playwright CDP round-trips exceeded 150 ms, letting the debounce fire first. Fix: install vi.useFakeTimers() after the stable-state setup (so vi.waitFor()'s real-timer polling still works), freeze the Walter debounce, let Escape trigger onExit/cancel, then advance fake time with vi.advanceTimersByTimeAsync() — no real-wall-clock race. Co-Authored-By: Claude Sonnet 4.6 --- .../PersonMentionEditor.svelte.spec.ts | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts index 3b58a62f..a0486e6d 100644 --- a/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/shared/discussion/PersonMentionEditor.svelte.spec.ts @@ -409,19 +409,24 @@ describe('PersonMentionEditor — onExit cancels pending debounce', () => { await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); const fetchesBeforeEscape = fetchMock.mock.calls.length; - // Trigger a new debounced search (queues runSearch after 150 ms), then - // immediately Escape *while focus is back in the editor* so Tiptap's - // suggestion-plugin Escape handler fires onExit before the debounce. - // Without onExit cancelling the pending debounce, runSearch executes - // against the now-unmounted dropdown's state. - await page.getByRole('searchbox').fill('Walter'); - // Focus the editor so the Escape lands on Tiptap's suggestion handler. - (page.getByRole('textbox').element() as HTMLElement).focus(); - await userEvent.keyboard('{Escape}'); - - // Wait past the debounce window. If onExit did not cancel the pending - // debounce, a fetch with q=Walter would still fire here. - await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); + // Freeze setTimeout so the 150 ms debounce cannot fire before Escape + // triggers onExit. We install fake timers only now — after the setup + // above — so that vi.waitFor()'s real-timer polling still worked. + vi.useFakeTimers(); + try { + // fill() dispatches the input event synchronously via CDP; by the + // time the await resolves, onSearch('Walter') has run and the fake + // debounce timer is set. + await page.getByRole('searchbox').fill('Walter'); + // Focus the editor so the Escape lands on Tiptap's suggestion handler. + (page.getByRole('textbox').element() as HTMLElement).focus(); + await userEvent.keyboard('{Escape}'); + // onExit has now called debouncedSearch.cancel(). Advance past the + // debounce window — the cancelled timer must not fire. + await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS); + } finally { + vi.useRealTimers(); + } const newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape); const walterFetches = newFetches.filter( From cb91ed340ded4a2cf8fbd36e38a0584d91a1598c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:38:06 +0200 Subject: [PATCH 05/17] feat(tag): hasAnyDocuments recursive helper + unit tests Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/tag/tagUtils.test.ts | 29 +++++++++++++++++++++++++++ frontend/src/lib/tag/tagUtils.ts | 7 +++++++ 2 files changed, 36 insertions(+) create mode 100644 frontend/src/lib/tag/tagUtils.test.ts create mode 100644 frontend/src/lib/tag/tagUtils.ts diff --git a/frontend/src/lib/tag/tagUtils.test.ts b/frontend/src/lib/tag/tagUtils.test.ts new file mode 100644 index 00000000..5fe4a874 --- /dev/null +++ b/frontend/src/lib/tag/tagUtils.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { hasAnyDocuments } from './tagUtils'; +import type { components } from '$lib/generated/api'; + +type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; + +function makeNode(documentCount: number, children: TagTreeNodeDTO[] = []): TagTreeNodeDTO { + return { id: 'id', name: 'name', documentCount, children }; +} + +describe('hasAnyDocuments', () => { + it('returns false for a leaf node with documentCount=0', () => { + expect(hasAnyDocuments(makeNode(0))).toBe(false); + }); + + it('returns true for a leaf node with documentCount=3', () => { + expect(hasAnyDocuments(makeNode(3))).toBe(true); + }); + + it('returns true for a root with documentCount=0 but a child with documentCount=5', () => { + const node = makeNode(0, [makeNode(5)]); + expect(hasAnyDocuments(node)).toBe(true); + }); + + it('returns false for a root with documentCount=0 and all children also 0', () => { + const node = makeNode(0, [makeNode(0), makeNode(0)]); + expect(hasAnyDocuments(node)).toBe(false); + }); +}); diff --git a/frontend/src/lib/tag/tagUtils.ts b/frontend/src/lib/tag/tagUtils.ts new file mode 100644 index 00000000..a7fe138d --- /dev/null +++ b/frontend/src/lib/tag/tagUtils.ts @@ -0,0 +1,7 @@ +import type { components } from '$lib/generated/api'; + +type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; + +export function hasAnyDocuments(node: TagTreeNodeDTO): boolean { + return (node.documentCount ?? 0) > 0 || (node.children ?? []).some(hasAnyDocuments); +} From 5b367a53a1e495c04d6f9e1cf16a6366ca6b6b53 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:39:06 +0200 Subject: [PATCH 06/17] feat(i18n): add themen widget and page translation keys Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 7 ++++++- frontend/messages/en.json | 7 ++++++- frontend/messages/es.json | 7 ++++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 52087452..25a17b1f 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -1084,5 +1084,10 @@ "timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt", "error_page_id_label": "Fehler-ID", "error_copy_id_label": "ID kopieren", - "error_copied": "Kopiert!" + "error_copied": "Kopiert!", + "themen_widget_title": "Themen", + "themen_alle": "Alle Themen", + "themen_leer": "Noch keine Themen vergeben.", + "themen_weitere": "+ {count} weitere", + "themen_dokumente": "{count} Dokumente" } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 3e2c3ff8..0289f7a6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1084,5 +1084,10 @@ "timeline_dragging_aria_live": "Range {from} to {to} selected", "error_page_id_label": "Error ID", "error_copy_id_label": "Copy ID", - "error_copied": "Copied!" + "error_copied": "Copied!", + "themen_widget_title": "Topics", + "themen_alle": "All Topics", + "themen_leer": "No topics assigned yet.", + "themen_weitere": "+ {count} more", + "themen_dokumente": "{count} documents" } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 972eecb8..cc3a5627 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -1084,5 +1084,10 @@ "timeline_dragging_aria_live": "Rango {from} a {to} seleccionado", "error_page_id_label": "ID de error", "error_copy_id_label": "Copiar ID", - "error_copied": "¡Copiado!" + "error_copied": "¡Copiado!", + "themen_widget_title": "Temas", + "themen_alle": "Todos los temas", + "themen_leer": "Aún no hay temas.", + "themen_weitere": "+ {count} más", + "themen_dokumente": "{count} documentos" } From 35017d91c4f39af8c619f5c45b3442015d8c9391 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:41:50 +0200 Subject: [PATCH 07/17] feat(themen): add /themen server load function + tests Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/themen/+page.server.ts | 12 ++++ .../src/routes/themen/page.server.spec.ts | 60 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 frontend/src/routes/themen/+page.server.ts create mode 100644 frontend/src/routes/themen/page.server.spec.ts diff --git a/frontend/src/routes/themen/+page.server.ts b/frontend/src/routes/themen/+page.server.ts new file mode 100644 index 00000000..d5d3891c --- /dev/null +++ b/frontend/src/routes/themen/+page.server.ts @@ -0,0 +1,12 @@ +import { error } from '@sveltejs/kit'; +import { createApiClient } from '$lib/shared/api.server'; +import type { components } from '$lib/generated/api'; + +type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; + +export async function load({ fetch }: Parameters[0]) { + const api = createApiClient(fetch); + const result = await api.GET('/api/tags/tree'); + if (!result.response.ok) throw error(500, 'Themen konnten nicht geladen werden.'); + return { tree: (result.data ?? []) as TagTreeNodeDTO[] }; +} diff --git a/frontend/src/routes/themen/page.server.spec.ts b/frontend/src/routes/themen/page.server.spec.ts new file mode 100644 index 00000000..338c4b40 --- /dev/null +++ b/frontend/src/routes/themen/page.server.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/shared/api.server', () => ({ + createApiClient: vi.fn(), + extractErrorCode: (e: unknown) => (e as { code?: string } | undefined)?.code +})); + +import { createApiClient } from '$lib/shared/api.server'; + +beforeEach(() => vi.clearAllMocks()); + +function mockApiGet(ok: boolean, data: unknown) { + vi.mocked(createApiClient).mockReturnValue({ + GET: vi.fn().mockResolvedValue({ response: { ok }, data }) + } as ReturnType); +} + +const makeTag = (name: string, documentCount = 0) => ({ + id: 'id-' + name, + name, + documentCount, + children: [] +}); + +describe('/themen +page.server load', () => { + function makeLoadEvent() { + return { + fetch: vi.fn() as unknown as typeof fetch, + request: new Request('http://localhost/themen'), + url: new URL('http://localhost/themen') + }; + } + + it('returns tag tree when API succeeds', async () => { + const tree = [makeTag('Briefe', 5), makeTag('Fotos', 3)]; + mockApiGet(true, tree); + + const { load } = await import('./+page.server'); + const result = await load(makeLoadEvent()); + + expect(result.tree).toEqual(tree); + }); + + it('returns empty array when API returns empty list', async () => { + mockApiGet(true, []); + + const { load } = await import('./+page.server'); + const result = await load(makeLoadEvent()); + + expect(result.tree).toEqual([]); + }); + + it('throws 500 when API call fails', async () => { + mockApiGet(false, null); + + const { load } = await import('./+page.server'); + + await expect(load(makeLoadEvent())).rejects.toMatchObject({ status: 500 }); + }); +}); From 15114c2d927009a00ffda6948460115aeaa9924a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:45:55 +0200 Subject: [PATCH 08/17] feat(dashboard): load tag tree for both reader and editor dashboard Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.server.ts | 20 ++++++++++++++++---- frontend/src/routes/page.server.spec.ts | 9 ++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index ed229042..11d74af1 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -12,6 +12,7 @@ type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; type DocumentListItem = components['schemas']['DocumentListItem']; type Geschichte = components['schemas']['Geschichte']; +type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; function settled(res: PromiseSettledResult | undefined): T | null { if (res?.status !== 'fulfilled') return null; @@ -40,7 +41,8 @@ export async function load({ fetch, parent }) { api.GET('/api/documents/search', { params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } } }), - api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }) + api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } }), + api.GET('/api/tags/tree') ]; if (canBlogWrite) { readerFetches.push( @@ -48,7 +50,7 @@ export async function load({ fetch, parent }) { ); } - const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] = + const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, tagTreeRes, draftsRes] = await Promise.allSettled(readerFetches); const readerStats = settled(statsRes); @@ -56,6 +58,7 @@ export async function load({ fetch, parent }) { const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes); const recentDocs = searchData?.items ?? []; const recentStories = settled(recentStoriesRes) ?? []; + const tagTree = settled(tagTreeRes) ?? []; const drafts = settled(draftsRes) ?? []; return { @@ -65,6 +68,7 @@ export async function load({ fetch, parent }) { topPersons, recentDocs, recentStories, + tagTree, drafts, error: null as string | null }; @@ -80,7 +84,8 @@ export async function load({ fetch, parent }) { readyResult, weeklyStatsResult, incompleteResult, - incompleteCountResult + incompleteCountResult, + tagTreeResult ] = await Promise.allSettled([ api.GET('/api/stats'), api.GET('/api/dashboard/resume'), @@ -91,7 +96,8 @@ export async function load({ fetch, parent }) { api.GET('/api/transcription/ready-to-read'), api.GET('/api/transcription/weekly-stats'), api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }), - api.GET('/api/documents/incomplete-count') + api.GET('/api/documents/incomplete-count'), + api.GET('/api/tags/tree') ]); let stats: StatsDTO | null = null; @@ -104,6 +110,7 @@ export async function load({ fetch, parent }) { let weeklyStats: TranscriptionWeeklyStatsDTO | null = null; let incompleteDocs: IncompleteDocumentDTO[] = []; let incompleteTotal = 0; + let tagTree: TagTreeNodeDTO[] = []; if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) { stats = statsResult.value.data ?? null; @@ -135,6 +142,9 @@ export async function load({ fetch, parent }) { if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) { incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0; } + if (tagTreeResult.status === 'fulfilled' && tagTreeResult.value.response.ok) { + tagTree = (tagTreeResult.value.data as TagTreeNodeDTO[]) ?? []; + } return { isReader: false as const, @@ -148,6 +158,7 @@ export async function load({ fetch, parent }) { weeklyStats, incompleteDocs, incompleteTotal, + tagTree, error: null as string | null }; } catch (e) { @@ -169,6 +180,7 @@ export async function load({ fetch, parent }) { topPersons: [] as PersonSummaryDTO[], recentDocs: [] as DocumentListItem[], recentStories: [] as Geschichte[], + tagTree: [] as TagTreeNodeDTO[], drafts: [] as Geschichte[], error: 'Daten konnten nicht geladen werden.' as string | null }; diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index a04cf1fe..66850c28 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -108,7 +108,8 @@ describe('home page load — dashboard', () => { data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 } }) // weekly-stats .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete - .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count + .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) // incomplete-count + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< typeof createApiClient >); @@ -146,7 +147,8 @@ describe('home page load — dashboard', () => { data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 } }) // weekly-stats .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete - .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count + .mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }) // incomplete-count + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< typeof createApiClient >); @@ -458,7 +460,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate .mockResolvedValueOnce(okStats) .mockReturnValueOnce(failPersons) .mockResolvedValueOnce(okSearch) - .mockResolvedValueOnce(okStories); + .mockResolvedValueOnce(okStories) + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // tags/tree vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< typeof createApiClient >); From 279b4f10980275022b5250d8f6dec7b2886a18cd Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:50:58 +0200 Subject: [PATCH 09/17] feat(themen): ThemenWidget component with compact prop + browser tests Co-Authored-By: Claude Sonnet 4.6 --- .../lib/shared/dashboard/ThemenWidget.svelte | 64 +++++++++++++++++++ .../dashboard/ThemenWidget.svelte.spec.ts | 58 +++++++++++++++++ .../{tag => shared/utils}/tagUtils.test.ts | 0 .../src/lib/{tag => shared/utils}/tagUtils.ts | 0 4 files changed, 122 insertions(+) create mode 100644 frontend/src/lib/shared/dashboard/ThemenWidget.svelte create mode 100644 frontend/src/lib/shared/dashboard/ThemenWidget.svelte.spec.ts rename frontend/src/lib/{tag => shared/utils}/tagUtils.test.ts (100%) rename frontend/src/lib/{tag => shared/utils}/tagUtils.ts (100%) diff --git a/frontend/src/lib/shared/dashboard/ThemenWidget.svelte b/frontend/src/lib/shared/dashboard/ThemenWidget.svelte new file mode 100644 index 00000000..b4ca9abf --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ThemenWidget.svelte @@ -0,0 +1,64 @@ + + +
+
+

+ {m.themen_widget_title()} +

+ + {m.themen_alle()} → + +
+ + {#if visibleTags.length === 0} +

{m.themen_leer()}

+ {:else} + + {/if} +
diff --git a/frontend/src/lib/shared/dashboard/ThemenWidget.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ThemenWidget.svelte.spec.ts new file mode 100644 index 00000000..521ddeba --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ThemenWidget.svelte.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import ThemenWidget from './ThemenWidget.svelte'; +import type { components } from '$lib/generated/api'; + +afterEach(() => { + cleanup(); +}); + +type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; + +function makeTag( + name: string, + documentCount: number, + children: TagTreeNodeDTO[] = [] +): TagTreeNodeDTO { + return { id: 'id-' + name, name, documentCount, children }; +} + +describe('ThemenWidget', () => { + it('renders a card link per visible tag', async () => { + const tags = [makeTag('Briefe', 5), makeTag('Fotos', 3)]; + const { getByRole } = render(ThemenWidget, { tags }); + await expect.element(getByRole('link', { name: /Briefe/ })).toBeInTheDocument(); + await expect.element(getByRole('link', { name: /Fotos/ })).toBeInTheDocument(); + }); + + it('hides tags where no document exists in the subtree', async () => { + const tags = [makeTag('Briefe', 5), makeTag('Leer', 0)]; + render(ThemenWidget, { tags }); + expect(document.body.textContent).toContain('Briefe'); + expect(document.body.textContent).not.toContain('Leer'); + }); + + it('shows the empty state text when all tags are filtered out', async () => { + render(ThemenWidget, { tags: [makeTag('Leer', 0)] }); + expect(document.body.textContent).toMatch(/Noch keine Themen/); + }); + + it('shows empty state when tags array is empty', async () => { + render(ThemenWidget, { tags: [] }); + expect(document.body.textContent).toMatch(/Noch keine Themen/); + }); + + it('renders in compact single-column mode when compact prop is true', async () => { + const tags = [makeTag('Briefe', 5)]; + const { container } = render(ThemenWidget, { tags, compact: true }); + const grid = container.querySelector('[data-compact="true"]'); + expect(grid).not.toBeNull(); + }); + + it('links to "Alle Themen" page', async () => { + const tags = [makeTag('Briefe', 5)]; + const { getByRole } = render(ThemenWidget, { tags }); + const link = getByRole('link', { name: /Alle Themen/ }); + await expect.element(link).toHaveAttribute('href', '/themen'); + }); +}); diff --git a/frontend/src/lib/tag/tagUtils.test.ts b/frontend/src/lib/shared/utils/tagUtils.test.ts similarity index 100% rename from frontend/src/lib/tag/tagUtils.test.ts rename to frontend/src/lib/shared/utils/tagUtils.test.ts diff --git a/frontend/src/lib/tag/tagUtils.ts b/frontend/src/lib/shared/utils/tagUtils.ts similarity index 100% rename from frontend/src/lib/tag/tagUtils.ts rename to frontend/src/lib/shared/utils/tagUtils.ts From 53c8d6e9f092b5aae3affe1709759e748b05815f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:51:44 +0200 Subject: [PATCH 10/17] feat(dashboard): add ThemenWidget to reader and editor sidebar layouts Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.svelte | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 4b5069e0..4d6bc7e2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -10,6 +10,7 @@ import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte'; import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte'; import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte'; import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte'; +import ThemenWidget from '$lib/shared/dashboard/ThemenWidget.svelte'; import { m } from '$lib/paraglide/messages.js'; let { data } = $props(); @@ -45,6 +46,8 @@ const greetingText = $derived.by(() => { + +
@@ -82,6 +85,7 @@ const greetingText = $derived.by(() => {
+ {#if data.canWrite} (bannerCount = count)} /> From 49a17b581bfc956dde1043a55ac9b12c473ab3f8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:52:43 +0200 Subject: [PATCH 11/17] feat(themen): /themen dedicated page with root-tag cards and child rows Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/themen/+page.svelte | 85 +++++++++++++++++++ .../src/routes/themen/page.svelte.spec.ts | 57 +++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 frontend/src/routes/themen/+page.svelte create mode 100644 frontend/src/routes/themen/page.svelte.spec.ts diff --git a/frontend/src/routes/themen/+page.svelte b/frontend/src/routes/themen/+page.svelte new file mode 100644 index 00000000..e88a2d5d --- /dev/null +++ b/frontend/src/routes/themen/+page.svelte @@ -0,0 +1,85 @@ + + + + {m.themen_widget_title()} + + +
+
+ +

{m.themen_widget_title()}

+
+ + {#if visibleTree.length === 0} +

{m.themen_leer()}

+ {:else} +
+ {#each visibleTree as tag (tag.id)} + {@const visibleChildren = (tag.children ?? []).filter(hasAnyDocuments)} + {@const shownChildren = visibleChildren.slice(0, MAX_VISIBLE_CHILDREN)} + {@const hiddenCount = visibleChildren.length - shownChildren.length} + + + {/each} +
+ {/if} +
diff --git a/frontend/src/routes/themen/page.svelte.spec.ts b/frontend/src/routes/themen/page.svelte.spec.ts new file mode 100644 index 00000000..d9d9634a --- /dev/null +++ b/frontend/src/routes/themen/page.svelte.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import ThemenPage from './+page.svelte'; +import type { components } from '$lib/generated/api'; + +afterEach(() => { + cleanup(); +}); + +type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO']; + +function makeTag( + name: string, + documentCount: number, + children: TagTreeNodeDTO[] = [] +): TagTreeNodeDTO { + return { id: 'id-' + name, name, documentCount, children }; +} + +describe('/themen +page', () => { + it('renders one card per visible root tag', async () => { + const tree = [makeTag('Briefe', 5), makeTag('Fotos', 3)]; + render(ThemenPage, { data: { tree } }); + expect(document.body.textContent).toContain('Briefe'); + expect(document.body.textContent).toContain('Fotos'); + }); + + it('does not render a tag with no documents in its subtree', async () => { + const tree = [makeTag('Briefe', 5), makeTag('Leer', 0)]; + render(ThemenPage, { data: { tree } }); + expect(document.body.textContent).not.toContain('Leer'); + }); + + it('shows empty state when all tags filtered out', async () => { + render(ThemenPage, { data: { tree: [makeTag('Leer', 0)] } }); + expect(document.body.textContent).toMatch(/Noch keine Themen/); + }); + + it('shows empty state when tree is empty', async () => { + render(ThemenPage, { data: { tree: [] } }); + expect(document.body.textContent).toMatch(/Noch keine Themen/); + }); + + it('renders child tags for a root tag', async () => { + const tree = [makeTag('Briefe', 5, [makeTag('Brautbriefe', 3), makeTag('Kriegsbriefe', 2)])]; + render(ThemenPage, { data: { tree } }); + expect(document.body.textContent).toContain('Brautbriefe'); + expect(document.body.textContent).toContain('Kriegsbriefe'); + }); + + it('shows "+ N weitere" when a root tag has more than 5 children', async () => { + const children = Array.from({ length: 7 }, (_, i) => makeTag(`Kind${i}`, i + 1)); + const tree = [makeTag('Briefe', 10, children)]; + render(ThemenPage, { data: { tree } }); + expect(document.body.textContent).toMatch(/\+\s*2\s*weitere/); + }); +}); From a45652466e6f89aa791498dff205f3591d0155e0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:55:31 +0200 Subject: [PATCH 12/17] docs(architecture): add /themen route and ThemenWidget to C4 frontend diagram Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 1 + docs/architecture/c4/l3-frontend-3c-people-stories.puml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 10a3c368..c36ba70c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,6 +197,7 @@ frontend/src/routes/ ├── aktivitaeten/ Unified activity feed (Chronik) ├── geschichten/ Stories — list, [id], [id]/edit, new ├── stammbaum/ Family tree (Stammbaum) +├── themen/ Topics directory — browsable tag index ├── enrich/ Enrichment workflow — [id], done ├── admin/ User, group, tag, OCR, system management ├── hilfe/transkription/ Transcription help page diff --git a/docs/architecture/c4/l3-frontend-3c-people-stories.puml b/docs/architecture/c4/l3-frontend-3c-people-stories.puml index 49526211..abfbea5e 100644 --- a/docs/architecture/c4/l3-frontend-3c-people-stories.puml +++ b/docs/architecture/c4/l3-frontend-3c-people-stories.puml @@ -14,6 +14,7 @@ System_Boundary(frontend, "Web Frontend (SvelteKit / SSR)") { Component(geschichten, "/geschichten and /geschichten/[id]", "SvelteKit Routes", "Story list and detail pages. Loader: GET /api/geschichten?status=PUBLISHED.") Component(geschichtenEdit, "/geschichten/[id]/edit and /geschichten/new", "SvelteKit Routes", "Story editor with rich text, person and document linking. Actions: PUT/POST /api/geschichten. Requires BLOG_WRITE permission.") Component(stammbaum, "/stammbaum", "SvelteKit Route", "Family tree visualisation. Loader: GET /api/network (nodes + edges). Renders interactive family tree from network graph data.") + Component(themen, "/themen", "SvelteKit Route", "Browsable topic index. Shows all root tags as cards with color bars and child rows. ThemenWidget also embedded in the home dashboard (reader + editor sidebar). Loader: GET /api/tags/tree.") Component(profilePage, "/profile", "SvelteKit Route", "Current user profile settings. Loader: GET /api/users/me/notification-preferences. Actions: update name/password and notification preferences.") Component(userProfile, "/users/[id]", "SvelteKit Route", "Public user profile view. Loader: GET /api/users/{id}.") } @@ -26,6 +27,7 @@ Rel(aktivitaeten, backend, "GET /api/dashboard/activity, GET /api/notifications" Rel(geschichten, backend, "GET /api/geschichten", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON") Rel(stammbaum, backend, "GET /api/network", "HTTP / JSON") +Rel(themen, backend, "GET /api/tags/tree", "HTTP / JSON") Rel(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON") Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON") From 80d77a53e9b69183cb5ae0b287bb36f446bf86b3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 18:05:46 +0200 Subject: [PATCH 13/17] fix(themen): add focus rings to child and 'weitere' links (WCAG 2.4.7) Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/themen/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/themen/+page.svelte b/frontend/src/routes/themen/+page.svelte index e88a2d5d..1a2a02c6 100644 --- a/frontend/src/routes/themen/+page.svelte +++ b/frontend/src/routes/themen/+page.svelte @@ -59,7 +59,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments)); {#each shownChildren as child (child.id)} {child.name} @@ -72,7 +72,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments)); {#if hiddenCount > 0} {m.themen_weitere({ count: hiddenCount })} → From e6a0c2f6d6bd42667ca5680bc8608c21c6d4a0b3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 19:02:43 +0200 Subject: [PATCH 14/17] feat(dashboard): move ThemenWidget to full-width position Editor view: lifted out of sidebar, now spans full width between DashboardResumeStrip and EnrichmentBlock. Reader view: already below ReaderPersonChips, no change. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/+page.svelte | 57 +++++++++++++++++--------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 4d6bc7e2..669ba237 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -59,37 +59,40 @@ const greetingText = $derived.by(() => {

{greetingText}

{/if} -
-
- +
+ - (bannerCount = 0)} - /> + -
-

- {m.dashboard_mission_caption()} -

- +
+ (bannerCount = 0)} /> -
-
-
- - - - {#if data.canWrite} - (bannerCount = count)} /> - {/if} +
+

+ {m.dashboard_mission_caption()} +

+ +
+
+ +
+ + + {#if data.canWrite} + (bannerCount = count)} /> + {/if} +
{/if} From 264d60c85529c7fda183a6958d50214217a99011 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 19:06:56 +0200 Subject: [PATCH 15/17] =?UTF-8?q?feat(themen):=20cap=20ThemenWidget=20at?= =?UTF-8?q?=206=20tags=20=E2=80=94=20link=20to=20/themen=20for=20full=20li?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/shared/dashboard/ThemenWidget.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/shared/dashboard/ThemenWidget.svelte b/frontend/src/lib/shared/dashboard/ThemenWidget.svelte index b4ca9abf..107e570a 100644 --- a/frontend/src/lib/shared/dashboard/ThemenWidget.svelte +++ b/frontend/src/lib/shared/dashboard/ThemenWidget.svelte @@ -10,9 +10,12 @@ interface Props { compact?: boolean; } +const MAX_VISIBLE_TAGS = 6; + const { tags, compact = false }: Props = $props(); const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments)); +const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS));
@@ -35,7 +38,7 @@ const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments)); class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}" data-compact={compact} > - {#each visibleTags as tag (tag.id)} + {#each shownTags as tag (tag.id)} --- frontend/src/lib/shared/dashboard/ThemenWidget.svelte | 4 ++-- frontend/src/routes/themen/+page.svelte | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/shared/dashboard/ThemenWidget.svelte b/frontend/src/lib/shared/dashboard/ThemenWidget.svelte index 107e570a..cef35418 100644 --- a/frontend/src/lib/shared/dashboard/ThemenWidget.svelte +++ b/frontend/src/lib/shared/dashboard/ThemenWidget.svelte @@ -25,7 +25,7 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS)); {m.themen_alle()} → @@ -40,7 +40,7 @@ const shownTags = $derived(visibleTags.slice(0, MAX_VISIBLE_TAGS)); > {#each shownTags as tag (tag.id)} data.tree.filter(hasAnyDocuments)); >
data.tree.filter(hasAnyDocuments)); {#each shownChildren as child (child.id)} {child.name} @@ -71,7 +71,7 @@ const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments)); {#if hiddenCount > 0} {m.themen_weitere({ count: hiddenCount })} → From 3f3d5e530c523f7bbee52bdfeaa5f6dae9b59772 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 19:45:28 +0200 Subject: [PATCH 17/17] test(dashboard): add missing tag tree mock to recentDocs reader test The sequential mock chain in the recentDocs test was missing a 6th call for /api/tags/tree added in the tag tree fetch. Without it the mock returned undefined, causing settled() to throw and the outer catch to return an empty recentDocs array. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/page.server.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 66850c28..65d2002c 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -421,7 +421,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate response: { ok: true }, data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 } }) // search - .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // stories + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // stories + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< typeof createApiClient >);