From cb91ed340ded4a2cf8fbd36e38a0584d91a1598c Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:38:06 +0200 Subject: [PATCH 01/13] 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); +} -- 2.49.1 From 5b367a53a1e495c04d6f9e1cf16a6366ca6b6b53 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:39:06 +0200 Subject: [PATCH 02/13] 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" } -- 2.49.1 From 35017d91c4f39af8c619f5c45b3442015d8c9391 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:41:50 +0200 Subject: [PATCH 03/13] 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 }); + }); +}); -- 2.49.1 From 15114c2d927009a00ffda6948460115aeaa9924a Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:45:55 +0200 Subject: [PATCH 04/13] 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 >); -- 2.49.1 From 279b4f10980275022b5250d8f6dec7b2886a18cd Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:50:58 +0200 Subject: [PATCH 05/13] 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 -- 2.49.1 From 53c8d6e9f092b5aae3affe1709759e748b05815f Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:51:44 +0200 Subject: [PATCH 06/13] 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)} /> -- 2.49.1 From 49a17b581bfc956dde1043a55ac9b12c473ab3f8 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:52:43 +0200 Subject: [PATCH 07/13] 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/); + }); +}); -- 2.49.1 From a45652466e6f89aa791498dff205f3591d0155e0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 17:55:31 +0200 Subject: [PATCH 08/13] 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") -- 2.49.1 From 80d77a53e9b69183cb5ae0b287bb36f446bf86b3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 18:05:46 +0200 Subject: [PATCH 09/13] 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 })} → -- 2.49.1 From e6a0c2f6d6bd42667ca5680bc8608c21c6d4a0b3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 19:02:43 +0200 Subject: [PATCH 10/13] 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} -- 2.49.1 From 264d60c85529c7fda183a6958d50214217a99011 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 19:06:56 +0200 Subject: [PATCH 11/13] =?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 })} → -- 2.49.1 From 3f3d5e530c523f7bbee52bdfeaa5f6dae9b59772 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 19:45:28 +0200 Subject: [PATCH 13/13] 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 >); -- 2.49.1