Merge remote-tracking branch 'origin/main' into HEAD
Some checks failed
CI / Unit & Component Tests (pull_request) Successful in 3m30s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m46s
CI / fail2ban Regex (pull_request) Failing after 46s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s

# Conflicts:
#	frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts
#	frontend/src/routes/+page.server.ts
This commit is contained in:
Marcel
2026-05-27 22:16:26 +02:00
26 changed files with 594 additions and 93 deletions

View File

@@ -2242,10 +2242,10 @@ export interface components {
totalStories: number;
};
PersonSummaryDTO: {
title?: string;
/** Format: uuid */
id?: string;
displayName?: string;
title?: string;
firstName?: string;
lastName?: string;
/** Format: int64 */
@@ -2364,8 +2364,6 @@ export interface components {
/** Format: int32 */
totalPages?: number;
pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */
size?: number;
content?: components["schemas"]["NotificationDTO"][];
@@ -2374,6 +2372,8 @@ export interface components {
sort?: components["schemas"]["SortObject"];
/** Format: int32 */
numberOfElements?: number;
first?: boolean;
last?: boolean;
empty?: boolean;
};
PageableObject: {
@@ -2460,6 +2460,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"][];

View File

@@ -3,16 +3,16 @@ 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 {
return new Date(doc.createdAt).getTime() === new Date(doc.updatedAt).getTime();
function isNew(doc: DocumentListItem): boolean {
return new Date(doc.createdAt).getTime() > Date.now() - 7 * 24 * 60 * 60 * 1000;
}
</script>

View File

@@ -5,25 +5,34 @@ 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',
metaDatePrecision: 'UNKNOWN',
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,8 +98,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: 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);
await expect.element(badge).toBeInTheDocument();
const cls = ((await badge.element()) as HTMLElement).className;
@@ -99,7 +114,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();
@@ -107,20 +122,20 @@ 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: DocumentListItem = {
...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();
});
it('renders sender name text when sender is present', async () => {
const docWithSender: Document = {
const docWithSender: DocumentListItem = {
...baseDoc,
sender: {
id: 'p1',

View File

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

View File

@@ -0,0 +1,67 @@
<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 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));
</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="flex min-h-[44px] items-center text-[11px] font-semibold text-ink-2 no-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 shownTags as tag (tag.id)}
<a
href="/documents?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

@@ -417,19 +417,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(

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 IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
type Document = components['schemas']['Document'];
type DocumentListItem = components['schemas']['DocumentListItem'];
type Geschichte = components['schemas']['Geschichte'];
type TagTreeNodeDTO = components['schemas']['TagTreeNodeDTO'];
function settled<T>(res: PromiseSettledResult<unknown> | 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,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);
const readerStats = settled<StatsDTO>(statsRes);
const topPersons = settled<{ items: PersonSummaryDTO[] }>(topPersonsRes)?.items ?? [];
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<Geschichte[]>(recentStoriesRes) ?? [];
const tagTree = settled<TagTreeNodeDTO[]>(tagTreeRes) ?? [];
const drafts = settled<Geschichte[]>(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) {
@@ -167,8 +178,9 @@ export async function load({ fetch, parent }) {
incompleteTotal: 0,
readerStats: null,
topPersons: [] as PersonSummaryDTO[],
recentDocs: [] as Document[],
recentDocs: [] as DocumentListItem[],
recentStories: [] as Geschichte[],
tagTree: [] as TagTreeNodeDTO[],
drafts: [] as Geschichte[],
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 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(() => {
<ReaderPersonChips persons={data.topPersons ?? []} />
<ThemenWidget tags={data.tagTree ?? []} />
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<ReaderRecentDocs documents={data.recentDocs ?? []} />
<ReaderRecentStories stories={data.recentStories ?? []} />
@@ -56,36 +59,40 @@ const greetingText = $derived.by(() => {
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
</div>
{/if}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<div class="flex flex-col gap-5">
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
<EnrichmentBlock
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
/>
<ThemenWidget tags={data.tagTree ?? []} />
<section aria-label={m.dashboard_mission_caption()}>
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_mission_caption()}
</h2>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="flex flex-col gap-5">
<EnrichmentBlock
topDocs={data.incompleteDocs ?? []}
totalCount={data.incompleteTotal ?? 0}
bannerCount={bannerCount}
onBannerClose={() => (bannerCount = 0)}
/>
</section>
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if}
<section aria-label={m.dashboard_mission_caption()}>
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_mission_caption()}
</h2>
<MissionControlStrip
segmentationDocs={data.segmentationDocs ?? []}
transcriptionDocs={data.transcriptionDocs ?? []}
readyDocs={data.readyDocs ?? []}
weeklyStats={data.weeklyStats ?? null}
/>
</section>
</div>
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
<DashboardFamilyPulse pulse={data.pulse ?? null} />
<DashboardActivityFeed feed={data.activityFeed ?? []} />
{#if data.canWrite}
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
{/if}
</div>
</div>
</div>
{/if}

View File

@@ -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
>);
@@ -394,6 +396,56 @@ 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 },
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
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // tags/tree
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 () => {
const okStats = {
response: { ok: true, status: 200 },
@@ -409,7 +461,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
>);

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="/documents?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="/documents?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="/documents?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/);
});
});