Compare commits

...

13 Commits

Author SHA1 Message Date
Marcel
80d77a53e9 fix(themen): add focus rings to child and 'weitere' links (WCAG 2.4.7)
Some checks failed
CI / OCR Service Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / fail2ban Regex (pull_request) Has been cancelled
CI / Semgrep Security Scan (pull_request) Has been cancelled
CI / Compose Bucket Idempotency (pull_request) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
a45652466e docs(architecture): add /themen route and ThemenWidget to C4 frontend diagram
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
49a17b581b feat(themen): /themen dedicated page with root-tag cards and child rows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
53c8d6e9f0 feat(dashboard): add ThemenWidget to reader and editor sidebar layouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
279b4f1098 feat(themen): ThemenWidget component with compact prop + browser tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
15114c2d92 feat(dashboard): load tag tree for both reader and editor dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
35017d91c4 feat(themen): add /themen server load function + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
5b367a53a1 feat(i18n): add themen widget and page translation keys
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
cb91ed340d feat(tag): hasAnyDocuments recursive helper + unit tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:52:37 +02:00
Marcel
2e0eb40aec test(debounce): fix flaky onExit-cancels-debounce test
All checks were successful
CI / fail2ban Regex (push) Successful in 42s
CI / Unit & Component Tests (pull_request) Successful in 4m5s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m35s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
CI / Unit & Component Tests (push) Successful in 3m46s
CI / OCR Service Tests (push) Successful in 22s
CI / Backend Unit Tests (push) Successful in 3m27s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
nightly / deploy-staging (push) Successful in 2m13s
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 <noreply@anthropic.com>
2026-05-25 17:40:10 +02:00
Marcel
d9e01ef1ff fix(review): regenerate api.ts and fix spec type
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m23s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m55s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 24s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
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 <noreply@anthropic.com>
2026-05-25 17:25:46 +02:00
Marcel
2e0f85c360 fix(review): address reviewer concerns from PR #661
All checks were successful
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
CI / Unit & Component Tests (pull_request) Successful in 3m50s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m50s
CI / fail2ban Regex (pull_request) Successful in 43s
- 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 <noreply@anthropic.com>
2026-05-25 15:08:04 +02:00
Marcel
a1035171c2 fix(reader-dashboard): recentDocs items were always undefined for READ_ALL users
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m45s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m42s
CI / fail2ban Regex (pull_request) Successful in 41s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 58s
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 <noreply@anthropic.com>
2026-05-25 14:31:55 +02:00
26 changed files with 561 additions and 67 deletions

View File

@@ -197,6 +197,7 @@ frontend/src/routes/
├── aktivitaeten/ Unified activity feed (Chronik) ├── aktivitaeten/ Unified activity feed (Chronik)
├── geschichten/ Stories — list, [id], [id]/edit, new ├── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum) ├── stammbaum/ Family tree (Stammbaum)
├── themen/ Topics directory — browsable tag index
├── enrich/ Enrichment workflow — [id], done ├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management ├── admin/ User, group, tag, OCR, system management
├── hilfe/transkription/ Transcription help page ├── hilfe/transkription/ Transcription help page

View File

@@ -6,6 +6,7 @@ import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.tag.Tag; import org.raddatz.familienarchiv.tag.Tag;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -32,5 +33,9 @@ public record DocumentListItem(
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
List<ActivityActorDTO> contributors, List<ActivityActorDTO> contributors,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) @Schema(requiredMode = Schema.RequiredMode.REQUIRED)
SearchMatchData matchData SearchMatchData matchData,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime createdAt,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime updatedAt
) {} ) {}

View File

@@ -767,7 +767,9 @@ public class DocumentService {
doc.getSummary(), doc.getSummary(),
completionPct, completionPct,
contributors, contributors,
match match,
doc.getCreatedAt(),
doc.getUpdatedAt()
); );
} }

View File

@@ -135,7 +135,8 @@ class DocumentControllerTest {
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null, docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
0, List.of(), matchData)))); 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")) mockMvc.perform(get("/api/documents/search").param("q", "Brief"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -153,7 +154,8 @@ class DocumentControllerTest {
.thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem( .thenReturn(DocumentSearchResult.of(List.of(new DocumentListItem(
docId, "Brief an Anna", "brief.pdf", null, null, null, docId, "Brief an Anna", "brief.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
0, List.of(), matchData)))); 0, List.of(), matchData,
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0)))));
mockMvc.perform(get("/api/documents/search")) mockMvc.perform(get("/api/documents/search"))
.andExpect(status().isOk()) .andExpect(status().isOk())

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.ActivityActorDTO; import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@@ -16,7 +17,8 @@ class DocumentSearchResultTest {
return new DocumentListItem( return new DocumentListItem(
docId, "Test", "test.pdf", null, null, null, docId, "Test", "test.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
0, List.of(), SearchMatchData.empty()); 0, List.of(), SearchMatchData.empty(),
LocalDateTime.of(2026, 1, 15, 10, 0), LocalDateTime.of(2026, 1, 15, 10, 0));
} }
@Test @Test
@@ -66,7 +68,8 @@ class DocumentSearchResultTest {
DocumentListItem item = new DocumentListItem( DocumentListItem item = new DocumentListItem(
id, "T", "t.pdf", null, null, null, id, "T", "t.pdf", null, null, null,
List.of(), List.of(), null, null, null, null, List.of(), List.of(), null, null, null, null,
75, List.of(actor), SearchMatchData.empty()); 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)); DocumentSearchResult result = DocumentSearchResult.of(List.of(item));

View File

@@ -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(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(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(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(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}.") 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(geschichten, backend, "GET /api/geschichten", "HTTP / JSON")
Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON") Rel(geschichtenEdit, backend, "GET/PUT/POST /api/geschichten", "HTTP / JSON")
Rel(stammbaum, backend, "GET /api/network", "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(profilePage, backend, "GET/PUT /api/users/me, notification-preferences", "HTTP / JSON")
Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON") Rel(userProfile, backend, "GET /api/users/{id}", "HTTP / JSON")

View File

@@ -1084,5 +1084,10 @@
"timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt", "timeline_dragging_aria_live": "Zeitraum {from} bis {to} ausgewählt",
"error_page_id_label": "Fehler-ID", "error_page_id_label": "Fehler-ID",
"error_copy_id_label": "ID kopieren", "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"
} }

View File

@@ -1084,5 +1084,10 @@
"timeline_dragging_aria_live": "Range {from} to {to} selected", "timeline_dragging_aria_live": "Range {from} to {to} selected",
"error_page_id_label": "Error ID", "error_page_id_label": "Error ID",
"error_copy_id_label": "Copy 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"
} }

View File

@@ -1084,5 +1084,10 @@
"timeline_dragging_aria_live": "Rango {from} a {to} seleccionado", "timeline_dragging_aria_live": "Rango {from} a {to} seleccionado",
"error_page_id_label": "ID de error", "error_page_id_label": "ID de error",
"error_copy_id_label": "Copiar ID", "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"
} }

View File

@@ -23,9 +23,9 @@
"@eslint/compat": "^1.4.0", "@eslint/compat": "^1.4.0",
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@inlang/paraglide-js": "^2.5.0", "@inlang/paraglide-js": "^2.5.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.60.0",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.48.5", "@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
@@ -43,7 +43,7 @@
"globals": "^16.5.0", "globals": "^16.5.0",
"openapi-typescript": "^7.8.0", "openapi-typescript": "^7.8.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"playwright": "^1.56.1", "playwright": "^1.60.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
@@ -52,7 +52,7 @@
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.47.0",
"vite": "^7.2.2", "vite": "^7.3.3",
"vite-plugin-devtools-json": "^1.0.0", "vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.0.10", "vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"

View File

@@ -2205,10 +2205,10 @@ export interface components {
totalStories: number; totalStories: number;
}; };
PersonSummaryDTO: { PersonSummaryDTO: {
title?: string;
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
displayName?: string; displayName?: string;
title?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
/** Format: int64 */ /** Format: int64 */
@@ -2315,8 +2315,6 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
totalPages?: number; totalPages?: number;
pageable?: components["schemas"]["PageableObject"]; pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */ /** Format: int32 */
size?: number; size?: number;
content?: components["schemas"]["NotificationDTO"][]; content?: components["schemas"]["NotificationDTO"][];
@@ -2325,6 +2323,8 @@ export interface components {
sort?: components["schemas"]["SortObject"]; sort?: components["schemas"]["SortObject"];
/** Format: int32 */ /** Format: int32 */
numberOfElements?: number; numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean; empty?: boolean;
}; };
PageableObject: { PageableObject: {
@@ -2407,6 +2407,10 @@ export interface components {
completionPercentage: number; completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][]; contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"]; matchData: components["schemas"]["SearchMatchData"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
updatedAt: string;
}; };
DocumentSearchResult: { DocumentSearchResult: {
items: components["schemas"]["DocumentListItem"][]; items: components["schemas"]["DocumentListItem"][];

View File

@@ -3,16 +3,16 @@ import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime'; import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type Document = components['schemas']['Document']; type DocumentListItem = components['schemas']['DocumentListItem'];
interface Props { interface Props {
documents: Document[]; documents: DocumentListItem[];
} }
const { documents }: Props = $props(); 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(); return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000;
} }
</script> </script>

View File

@@ -5,24 +5,33 @@ import { page } from 'vitest/browser';
import ReaderRecentDocs from './ReaderRecentDocs.svelte'; import ReaderRecentDocs from './ReaderRecentDocs.svelte';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type Document = components['schemas']['Document']; type DocumentListItem = components['schemas']['DocumentListItem'];
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
const baseDoc: Document = { const baseDoc: DocumentListItem = {
id: 'doc1', id: 'doc1',
title: 'Brief an Hans', title: 'Brief an Hans',
originalFilename: 'brief.pdf', originalFilename: 'brief.pdf',
status: 'UPLOADED', completionPercentage: 0,
metadataComplete: true, receivers: [],
scriptType: 'HANDWRITING_KURRENT', tags: [],
contributors: [],
matchData: {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
},
createdAt: '2025-01-01T12:00:00Z', createdAt: '2025-01-01T12:00:00Z',
updatedAt: '2025-01-01T12:00:00Z' updatedAt: '2025-01-01T12:00:00Z'
}; };
const updatedDoc: Document = { const updatedDoc: DocumentListItem = {
...baseDoc, ...baseDoc,
id: 'doc2', id: 'doc2',
title: 'Urkunde 1920', title: 'Urkunde 1920',
@@ -88,8 +97,14 @@ describe('ReaderRecentDocs', () => {
expect(thumb!.className).toMatch(/rounded-/); expect(thumb!.className).toMatch(/rounded-/);
}); });
it('shows "Neu" accent-pill badge when createdAt equals updatedAt', async () => { it('shows "Neu" accent-pill badge when document was created within the last 7 days', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] }); const recentDoc: DocumentListItem = {
...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); const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument(); await expect.element(badge).toBeInTheDocument();
const cls = ((await badge.element()) as HTMLElement).className; const cls = ((await badge.element()) as HTMLElement).className;
@@ -98,7 +113,7 @@ describe('ReaderRecentDocs', () => {
expect(cls).toMatch(/\btext-ink\b/); 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] }); render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Neu$/i); const badge = page.getByText(/^Neu$/i);
await expect.element(badge).not.toBeInTheDocument(); await expect.element(badge).not.toBeInTheDocument();
@@ -106,20 +121,20 @@ describe('ReaderRecentDocs', () => {
await expect.element(updatedBadge).not.toBeInTheDocument(); await expect.element(updatedBadge).not.toBeInTheDocument();
}); });
it('shows "Neu" badge when createdAt and updatedAt represent the same instant in different ISO formats', async () => { it('shows "Neu" badge when document was created 6 days ago', async () => {
const sameInstantDoc: Document = { const almostOldDoc: DocumentListItem = {
...baseDoc, ...baseDoc,
id: 'doc-same-instant', id: 'doc-almost-old',
createdAt: '2025-01-01T12:00:00Z', createdAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: '2025-01-01T12:00:00.000Z' 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); const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument(); await expect.element(badge).toBeInTheDocument();
}); });
it('renders sender name text when sender is present', async () => { it('renders sender name text when sender is present', async () => {
const docWithSender: Document = { const docWithSender: DocumentListItem = {
...baseDoc, ...baseDoc,
sender: { sender: {
id: 'p1', id: 'p1',

View File

@@ -31,25 +31,25 @@ describe('ReaderRecentDocs', () => {
.toHaveAttribute('href', '/documents'); .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, { render(ReaderRecentDocs, {
props: { props: {
documents: [ documents: [makeDoc({ createdAt: recentDate, updatedAt: laterUpdate })]
makeDoc({ createdAt: '2026-04-15T10:00:00Z', updatedAt: '2026-04-15T10:00:00Z' })
]
} }
}); });
await expect.element(page.getByText('Neu')).toBeVisible(); 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, { render(ReaderRecentDocs, {
props: { props: {
documents: [ documents: [
makeDoc({ makeDoc({
createdAt: '2026-04-15T10:00:00Z', createdAt: '2026-04-15T10:00:00Z',
updatedAt: '2026-04-15T11:00:00Z' updatedAt: '2026-04-15T10:00:00Z'
}) })
] ]
} }

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import { hasAnyDocuments } from '$lib/shared/utils/tagUtils';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
interface Props {
tags: TagTreeNodeDTO[];
compact?: boolean;
}
const { tags, compact = false }: Props = $props();
const visibleTags = $derived.by(() => tags.filter(hasAnyDocuments));
</script>
<section class="rounded-sm border border-line bg-surface p-5 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.themen_widget_title()}
</h2>
<a
href="/themen"
class="font-sans text-xs text-brand-mint underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{m.themen_alle()}
</a>
</div>
{#if visibleTags.length === 0}
<p class="font-sans text-sm text-ink-3">{m.themen_leer()}</p>
{:else}
<div
class="grid gap-2 {compact ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2'}"
data-compact={compact}
>
{#each visibleTags as tag (tag.id)}
<a
href="/?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount })
: ''}"
class="flex cursor-pointer items-stretch overflow-hidden rounded-sm border border-line bg-canvas hover:bg-surface focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
style="min-height: 56px"
>
<span
class="w-1 flex-shrink-0 self-stretch"
aria-hidden="true"
style="background: var(--c-tag-{tag.color ?? 'slate'})"
></span>
<span class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 px-3 py-3">
<span class="truncate font-serif text-sm font-semibold text-ink">{tag.name}</span>
{#if tag.documentCount > 0}
<span class="font-sans text-xs text-ink-3 tabular-nums">
{m.themen_dokumente({ count: tag.documentCount })}
</span>
{/if}
</span>
</a>
{/each}
</div>
{/if}
</section>

View File

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

View File

@@ -409,19 +409,24 @@ describe('PersonMentionEditor — onExit cancels pending debounce', () => {
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS));
const fetchesBeforeEscape = fetchMock.mock.calls.length; const fetchesBeforeEscape = fetchMock.mock.calls.length;
// Trigger a new debounced search (queues runSearch after 150 ms), then // Freeze setTimeout so the 150 ms debounce cannot fire before Escape
// immediately Escape *while focus is back in the editor* so Tiptap's // triggers onExit. We install fake timers only now — after the setup
// suggestion-plugin Escape handler fires onExit before the debounce. // above — so that vi.waitFor()'s real-timer polling still worked.
// Without onExit cancelling the pending debounce, runSearch executes vi.useFakeTimers();
// against the now-unmounted dropdown's state. try {
await page.getByRole('searchbox').fill('Walter'); // fill() dispatches the input event synchronously via CDP; by the
// Focus the editor so the Escape lands on Tiptap's suggestion handler. // time the await resolves, onSearch('Walter') has run and the fake
(page.getByRole('textbox').element() as HTMLElement).focus(); // debounce timer is set.
await userEvent.keyboard('{Escape}'); await page.getByRole('searchbox').fill('Walter');
// Focus the editor so the Escape lands on Tiptap's suggestion handler.
// Wait past the debounce window. If onExit did not cancel the pending (page.getByRole('textbox').element() as HTMLElement).focus();
// debounce, a fetch with q=Walter would still fire here. await userEvent.keyboard('{Escape}');
await new Promise((r) => setTimeout(r, SEARCH_DEBOUNCE_MS + POST_DEBOUNCE_SLACK_MS)); // 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 newFetches = fetchMock.mock.calls.slice(fetchesBeforeEscape);
const walterFetches = newFetches.filter( const walterFetches = newFetches.filter(

View File

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

View File

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

View File

@@ -10,8 +10,9 @@ type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO']; type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO']; type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
type Document = components['schemas']['Document']; type DocumentListItem = components['schemas']['DocumentListItem'];
type Geschichte = components['schemas']['Geschichte']; type Geschichte = components['schemas']['Geschichte'];
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null { function settled<T>(res: PromiseSettledResult<unknown> | undefined): T | null {
if (res?.status !== 'fulfilled') return null; if (res?.status !== 'fulfilled') return null;
@@ -40,7 +41,8 @@ export async function load({ fetch, parent }) {
api.GET('/api/documents/search', { api.GET('/api/documents/search', {
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } } 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) { if (canBlogWrite) {
readerFetches.push( readerFetches.push(
@@ -48,14 +50,15 @@ export async function load({ fetch, parent }) {
); );
} }
const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, draftsRes] = const [statsRes, topPersonsRes, recentDocsRes, recentStoriesRes, tagTreeRes, draftsRes] =
await Promise.allSettled(readerFetches); await Promise.allSettled(readerFetches);
const readerStats = settled<StatsDTO>(statsRes); const readerStats = settled<StatsDTO>(statsRes);
const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? []; const topPersons = settled<PersonSummaryDTO[]>(topPersonsRes) ?? [];
const searchData = settled<{ items: { document: Document }[] }>(recentDocsRes); const searchData = settled<{ items: DocumentListItem[] }>(recentDocsRes);
const recentDocs = searchData?.items.map((i) => i.document) ?? []; const recentDocs = searchData?.items ?? [];
const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? []; const recentStories = settled<Geschichte[]>(recentStoriesRes) ?? [];
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
const drafts = settled<Geschichte[]>(draftsRes) ?? []; const drafts = settled<Geschichte[]>(draftsRes) ?? [];
return { return {
@@ -65,6 +68,7 @@ export async function load({ fetch, parent }) {
topPersons, topPersons,
recentDocs, recentDocs,
recentStories, recentStories,
tagTree,
drafts, drafts,
error: null as string | null error: null as string | null
}; };
@@ -80,7 +84,8 @@ export async function load({ fetch, parent }) {
readyResult, readyResult,
weeklyStatsResult, weeklyStatsResult,
incompleteResult, incompleteResult,
incompleteCountResult incompleteCountResult,
tagTreeResult
] = await Promise.allSettled([ ] = await Promise.allSettled([
api.GET('/api/stats'), api.GET('/api/stats'),
api.GET('/api/dashboard/resume'), 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/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', { 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; let stats: StatsDTO | null = null;
@@ -104,6 +110,7 @@ export async function load({ fetch, parent }) {
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null; let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
let incompleteDocs: IncompleteDocumentDTO[] = []; let incompleteDocs: IncompleteDocumentDTO[] = [];
let incompleteTotal = 0; let incompleteTotal = 0;
let tagTree: TagTreeNodeDTO[] = [];
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) { if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
stats = statsResult.value.data ?? null; stats = statsResult.value.data ?? null;
@@ -135,6 +142,9 @@ export async function load({ fetch, parent }) {
if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) { if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) {
incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0; incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0;
} }
if (tagTreeResult.status === 'fulfilled' && tagTreeResult.value.response.ok) {
tagTree = (tagTreeResult.value.data as TagTreeNodeDTO[]) ?? [];
}
return { return {
isReader: false as const, isReader: false as const,
@@ -148,6 +158,7 @@ export async function load({ fetch, parent }) {
weeklyStats, weeklyStats,
incompleteDocs, incompleteDocs,
incompleteTotal, incompleteTotal,
tagTree,
error: null as string | null error: null as string | null
}; };
} catch (e) { } catch (e) {
@@ -167,8 +178,9 @@ export async function load({ fetch, parent }) {
incompleteTotal: 0, incompleteTotal: 0,
readerStats: null, readerStats: null,
topPersons: [] as PersonSummaryDTO[], topPersons: [] as PersonSummaryDTO[],
recentDocs: [] as Document[], recentDocs: [] as DocumentListItem[],
recentStories: [] as Geschichte[], recentStories: [] as Geschichte[],
tagTree: [] as TagTreeNodeDTO[],
drafts: [] as Geschichte[], drafts: [] as Geschichte[],
error: 'Daten konnten nicht geladen werden.' as string | null error: 'Daten konnten nicht geladen werden.' as string | null
}; };

View File

@@ -10,6 +10,7 @@ import ReaderPersonChips from '$lib/shared/dashboard/ReaderPersonChips.svelte';
import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte'; import ReaderDraftsModule from '$lib/shared/dashboard/ReaderDraftsModule.svelte';
import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte'; import ReaderRecentDocs from '$lib/shared/dashboard/ReaderRecentDocs.svelte';
import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte'; import ReaderRecentStories from '$lib/shared/dashboard/ReaderRecentStories.svelte';
import ThemenWidget from '$lib/shared/dashboard/ThemenWidget.svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
let { data } = $props(); let { data } = $props();
@@ -45,6 +46,8 @@ const greetingText = $derived.by(() => {
<ReaderPersonChips persons={data.topPersons ?? []} /> <ReaderPersonChips persons={data.topPersons ?? []} />
<ThemenWidget tags={data.tagTree ?? []} />
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<ReaderRecentDocs documents={data.recentDocs ?? []} /> <ReaderRecentDocs documents={data.recentDocs ?? []} />
<ReaderRecentStories stories={data.recentStories ?? []} /> <ReaderRecentStories stories={data.recentStories ?? []} />
@@ -82,6 +85,7 @@ const greetingText = $derived.by(() => {
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]"> <div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} /> <DashboardFamilyPulse pulse={data.pulse ?? null} />
<ThemenWidget tags={data.tagTree ?? []} compact={true} />
<DashboardActivityFeed feed={data.activityFeed ?? []} /> <DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite} {#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} /> <DropZone onUploadComplete={(count) => (bannerCount = count)} />

View File

@@ -108,7 +108,8 @@ describe('home page load — dashboard', () => {
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 } data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
}) // weekly-stats }) // weekly-stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete .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< vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient typeof createApiClient
>); >);
@@ -146,7 +147,8 @@ describe('home page load — dashboard', () => {
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 } data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
}) // weekly-stats }) // weekly-stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete .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< vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient typeof createApiClient
>); >);
@@ -394,6 +396,55 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
expect(result.isReader).toBe(false); 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 },
createdAt: '2026-05-01T10:00:00Z',
updatedAt: '2026-05-10T08:00:00Z'
};
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<typeof load>[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');
expect(result.recentDocs[0].createdAt).toBe('2026-05-01T10:00:00Z');
expect(result.recentDocs[0].updatedAt).toBe('2026-05-10T08:00:00Z');
}
});
it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => { it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => {
const okStats = { const okStats = {
response: { ok: true, status: 200 }, response: { ok: true, status: 200 },
@@ -409,7 +460,8 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
.mockResolvedValueOnce(okStats) .mockResolvedValueOnce(okStats)
.mockReturnValueOnce(failPersons) .mockReturnValueOnce(failPersons)
.mockResolvedValueOnce(okSearch) .mockResolvedValueOnce(okSearch)
.mockResolvedValueOnce(okStories); .mockResolvedValueOnce(okStories)
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // tags/tree
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient typeof createApiClient
>); >);

View File

@@ -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<import('./$types').PageServerLoad>[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[] };
}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import BackButton from '$lib/shared/primitives/BackButton.svelte';
import { hasAnyDocuments } from '$lib/shared/utils/tagUtils';
import type { components } from '$lib/generated/api';
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
const MAX_VISIBLE_CHILDREN = 5;
let { data }: { data: { tree: TagTreeNodeDTO[] } } = $props();
const visibleTree = $derived.by(() => data.tree.filter(hasAnyDocuments));
</script>
<svelte:head>
<title>{m.themen_widget_title()}</title>
</svelte:head>
<main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-6 flex items-center gap-3">
<BackButton />
<h1 class="font-serif text-2xl font-semibold text-ink">{m.themen_widget_title()}</h1>
</div>
{#if visibleTree.length === 0}
<p class="font-sans text-sm text-ink-3">{m.themen_leer()}</p>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#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}
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
<div
class="h-1.5 w-full flex-shrink-0"
aria-hidden="true"
style="background: var(--c-tag-{tag.color ?? 'slate'})"
></div>
<a
href="/?tag={encodeURIComponent(tag.name)}"
aria-label="{tag.name}{tag.documentCount > 0
? ', ' + m.themen_dokumente({ count: tag.documentCount })
: ''}"
class="flex min-h-[56px] items-center justify-between px-4 pt-4 pb-3 hover:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
>
<span class="font-serif text-base font-semibold text-ink">{tag.name}</span>
<span class="mr-1 ml-auto font-sans text-sm text-ink-3 tabular-nums">
{#if tag.documentCount > 0}{tag.documentCount}{/if}
</span>
<span aria-hidden="true" class="h-3.5 w-3.5 flex-shrink-0 text-brand-mint"></span>
</a>
{#if shownChildren.length > 0}
<div class="mx-4 border-t border-line"></div>
{#each shownChildren as child (child.id)}
<a
href="/?tag={encodeURIComponent(child.name)}"
class="flex min-h-[44px] items-center justify-between px-4 py-2.5 hover:bg-canvas focus-visible:bg-canvas focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
>
<span class="font-sans text-sm text-ink">{child.name}</span>
<span class="mr-1 ml-auto font-sans text-xs text-ink-3 tabular-nums">
{#if child.documentCount > 0}{child.documentCount}{/if}
</span>
<span aria-hidden="true" class="h-3 w-3 flex-shrink-0 text-brand-mint"></span>
</a>
{/each}
{#if hiddenCount > 0}
<a
href="/?tag={encodeURIComponent(tag.name)}"
class="block min-h-[44px] px-4 py-2.5 font-sans text-sm text-ink-3 hover:bg-canvas hover:text-ink focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none focus-visible:ring-inset"
>
{m.themen_weitere({ count: hiddenCount })}
</a>
{/if}
{/if}
</div>
{/each}
</div>
{/if}
</main>

View File

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

View File

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