diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
new file mode 100644
index 00000000..a03899e1
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte
@@ -0,0 +1,38 @@
+
+
+
+
+ {m.dashboard_reader_drafts_heading()}
+
+ {#if drafts.length === 0}
+
{m.dashboard_reader_drafts_empty()}
+ {:else}
+
+ {/if}
+
diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts
new file mode 100644
index 00000000..9d4beab8
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+import ReaderDraftsModule from './ReaderDraftsModule.svelte';
+import type { components } from '$lib/generated/api';
+
+type Geschichte = components['schemas']['Geschichte'];
+
+afterEach(() => {
+ cleanup();
+});
+
+const draft1: Geschichte = {
+ id: 'g1',
+ title: 'Mein erster Entwurf',
+ status: 'DRAFT',
+ createdAt: '2025-01-01T00:00:00Z',
+ updatedAt: '2025-01-02T00:00:00Z'
+};
+
+const draft2: Geschichte = {
+ id: 'g2',
+ title: 'Zweiter Entwurf',
+ status: 'DRAFT',
+ createdAt: '2025-02-01T00:00:00Z',
+ updatedAt: '2025-02-01T00:00:00Z'
+};
+
+describe('ReaderDraftsModule', () => {
+ it('renders a link to /geschichten/{id}/edit for each draft', async () => {
+ render(ReaderDraftsModule, { drafts: [draft1, draft2] });
+ const link1 = page.getByRole('link', { name: /Mein erster Entwurf/ });
+ await expect.element(link1).toHaveAttribute('href', '/geschichten/g1/edit');
+ const link2 = page.getByRole('link', { name: /Zweiter Entwurf/ });
+ await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
+ });
+
+ it('shows heading "Meine Entwürfe"', async () => {
+ render(ReaderDraftsModule, { drafts: [draft1] });
+ const heading = page.getByRole('heading', { name: /Meine Entwürfe/i });
+ await expect.element(heading).toBeInTheDocument();
+ });
+
+ it('shows empty state when drafts is empty', async () => {
+ render(ReaderDraftsModule, { drafts: [] });
+ const emptyText = page.getByText(/Keine Entwürfe/i);
+ await expect.element(emptyText).toBeInTheDocument();
+ });
+
+ it('does not show empty state when drafts are present', async () => {
+ render(ReaderDraftsModule, { drafts: [draft1] });
+ const emptyText = page.getByText(/Keine Entwürfe/i);
+ await expect.element(emptyText).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
new file mode 100644
index 00000000..6cddcfbd
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte
@@ -0,0 +1,56 @@
+
+
+
diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts
new file mode 100644
index 00000000..a24da959
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+import ReaderPersonChips from './ReaderPersonChips.svelte';
+import type { components } from '$lib/generated/api';
+
+type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
+
+afterEach(() => {
+ cleanup();
+});
+
+const person1: PersonSummaryDTO = {
+ id: 'aaaaaaaa-0000-0000-0000-000000000001',
+ firstName: 'Anna',
+ lastName: 'Müller',
+ displayName: 'Anna Müller',
+ documentCount: 23,
+ personType: 'PERSON',
+ familyMember: false
+};
+
+const person2: PersonSummaryDTO = {
+ id: 'aaaaaaaa-0000-0000-0000-000000000002',
+ firstName: 'Karl',
+ lastName: 'Schmidt',
+ displayName: 'Karl Schmidt',
+ documentCount: 5,
+ personType: 'PERSON',
+ familyMember: false
+};
+
+describe('ReaderPersonChips', () => {
+ it('renders a chip for each person with correct href', async () => {
+ render(ReaderPersonChips, { persons: [person1, person2] });
+ const link1 = page.getByRole('link', { name: /Anna Müller/ });
+ await expect
+ .element(link1)
+ .toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000001');
+ const link2 = page.getByRole('link', { name: /Karl Schmidt/ });
+ await expect
+ .element(link2)
+ .toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
+ });
+
+ it('shows document count in each chip', async () => {
+ render(ReaderPersonChips, { persons: [person1] });
+ const chip = page.getByRole('link', { name: /Anna Müller/ });
+ await expect.element(chip).toBeInTheDocument();
+ const text = ((await chip.element()) as HTMLElement).textContent;
+ expect(text).toContain('23');
+ });
+
+ it('renders an "Alle Personen" link to /persons', async () => {
+ render(ReaderPersonChips, { persons: [person1] });
+ const allLink = page.getByRole('link', { name: /Alle Personen/i });
+ await expect.element(allLink).toHaveAttribute('href', '/persons');
+ });
+
+ it('renders empty state without chips when persons array is empty', async () => {
+ render(ReaderPersonChips, { persons: [] });
+ const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
+ await expect.element(chips).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
new file mode 100644
index 00000000..3fd37db4
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte
@@ -0,0 +1,65 @@
+
+
+
+
+ {m.dashboard_reader_recent_docs_heading()}
+
+
+ {#each documents as doc (doc.id)}
+ -
+
+
+
+ {relativeTimeDe(new Date(doc.updatedAt))}
+
+
+
+ {/each}
+
+
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts
new file mode 100644
index 00000000..6d0f45f7
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+import ReaderRecentDocs from './ReaderRecentDocs.svelte';
+import type { components } from '$lib/generated/api';
+
+type Document = components['schemas']['Document'];
+
+afterEach(() => {
+ cleanup();
+});
+
+const baseDoc: Document = {
+ id: 'doc1',
+ title: 'Brief an Hans',
+ originalFilename: 'brief.pdf',
+ status: 'UPLOADED',
+ metadataComplete: true,
+ scriptType: 'HANDWRITING_KURRENT',
+ createdAt: '2025-01-01T12:00:00Z',
+ updatedAt: '2025-01-01T12:00:00Z'
+};
+
+const updatedDoc: Document = {
+ ...baseDoc,
+ id: 'doc2',
+ title: 'Urkunde 1920',
+ createdAt: '2025-01-01T12:00:00Z',
+ updatedAt: '2025-03-01T12:00:00Z'
+};
+
+describe('ReaderRecentDocs', () => {
+ it('renders a link to /documents/{id} for each document', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const link = page.getByRole('link', { name: /Brief an Hans/ });
+ await expect.element(link).toHaveAttribute('href', '/documents/doc1');
+ });
+
+ it('shows "Neu" badge when createdAt equals updatedAt', async () => {
+ render(ReaderRecentDocs, { documents: [baseDoc] });
+ const badge = page.getByText(/^Neu$/i);
+ await expect.element(badge).toBeInTheDocument();
+ });
+
+ it('shows "Aktualisiert" badge when updatedAt differs from createdAt', async () => {
+ render(ReaderRecentDocs, { documents: [updatedDoc] });
+ const badge = page.getByText(/^Aktualisiert$/i);
+ await expect.element(badge).toBeInTheDocument();
+ });
+
+ it('does not show "Neu" badge when updatedAt differs from createdAt', async () => {
+ render(ReaderRecentDocs, { documents: [updatedDoc] });
+ const badge = page.getByText(/^Neu$/i);
+ await expect.element(badge).not.toBeInTheDocument();
+ });
+
+ it('renders sender link when sender is present', async () => {
+ const docWithSender: Document = {
+ ...baseDoc,
+ sender: {
+ id: 'p1',
+ lastName: 'Müller',
+ firstName: 'Anna',
+ displayName: 'Anna Müller',
+ personType: 'PERSON' as const,
+ familyMember: false
+ }
+ };
+ render(ReaderRecentDocs, { documents: [docWithSender] });
+ const senderLink = page.getByRole('link', { name: /Anna Müller/ });
+ await expect.element(senderLink).toHaveAttribute('href', '/persons/p1');
+ });
+});
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
new file mode 100644
index 00000000..8c19b8ae
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte
@@ -0,0 +1,53 @@
+
+
+{#if stories.length > 0}
+
+{/if}
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts
new file mode 100644
index 00000000..df7f3891
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+import ReaderRecentStories from './ReaderRecentStories.svelte';
+import type { components } from '$lib/generated/api';
+
+type Geschichte = components['schemas']['Geschichte'];
+
+afterEach(() => {
+ cleanup();
+});
+
+const story1: Geschichte = {
+ id: 'g1',
+ title: 'Die Familie Müller',
+ body: 'Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.
',
+ status: 'PUBLISHED',
+ createdAt: '2025-01-01T00:00:00Z',
+ updatedAt: '2025-01-01T00:00:00Z',
+ publishedAt: '2025-01-01T00:00:00Z'
+};
+
+const longBodyStory: Geschichte = {
+ id: 'g2',
+ title: 'Sehr lange Geschichte',
+ body: '' + 'A'.repeat(200) + '
',
+ status: 'PUBLISHED',
+ createdAt: '2025-02-01T00:00:00Z',
+ updatedAt: '2025-02-01T00:00:00Z',
+ publishedAt: '2025-02-01T00:00:00Z'
+};
+
+describe('ReaderRecentStories', () => {
+ it('renders a link to /geschichten/{id} for each story', async () => {
+ render(ReaderRecentStories, { stories: [story1] });
+ const link = page.getByRole('link', { name: /Die Familie Müller/ });
+ await expect.element(link).toHaveAttribute('href', '/geschichten/g1');
+ });
+
+ it('truncates body excerpt to 150 characters and strips HTML', async () => {
+ render(ReaderRecentStories, { stories: [longBodyStory] });
+ const excerpt = page.getByText(/A{100,150}/);
+ await expect.element(excerpt).toBeInTheDocument();
+ const text = ((await excerpt.element()) as HTMLElement).textContent;
+ expect(text!.replace(/…$/, '').length).toBeLessThanOrEqual(150);
+ });
+
+ it('shows empty state when stories array is empty', async () => {
+ render(ReaderRecentStories, { stories: [] });
+ const links = page.getByRole('link');
+ await expect.element(links).not.toBeInTheDocument();
+ });
+
+ it('renders "Alle Geschichten" link', async () => {
+ render(ReaderRecentStories, { stories: [story1] });
+ const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
+ await expect.element(allLink).toHaveAttribute('href', '/geschichten');
+ });
+});
diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
new file mode 100644
index 00000000..8129b03c
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte
@@ -0,0 +1,43 @@
+
+
+
diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts
new file mode 100644
index 00000000..b33ddfc7
--- /dev/null
+++ b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
+import { page } from 'vitest/browser';
+
+import ReaderStatsStrip from './ReaderStatsStrip.svelte';
+
+afterEach(() => {
+ cleanup();
+});
+
+describe('ReaderStatsStrip', () => {
+ it('renders a link to /documents', async () => {
+ render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /42/ });
+ await expect.element(link).toHaveAttribute('href', '/documents');
+ });
+
+ it('renders a link to /persons', async () => {
+ render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /7/ });
+ await expect.element(link).toHaveAttribute('href', '/persons');
+ });
+
+ it('renders a link to /geschichten', async () => {
+ render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
+ const link = page.getByRole('link', { name: /3/ });
+ await expect.element(link).toHaveAttribute('href', '/geschichten');
+ });
+
+ it('shows "—" when documents count is null', async () => {
+ render(ReaderStatsStrip, { documents: null, persons: null, stories: null });
+ const links = page.getByRole('link');
+ await expect.element(links.first()).toBeInTheDocument();
+ const text = ((await links.first().element()) as HTMLElement).textContent;
+ expect(text).toContain('—');
+ });
+});
diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts
index 9643fa85..57dc7c54 100644
--- a/frontend/src/routes/+page.server.ts
+++ b/frontend/src/routes/+page.server.ts
@@ -29,7 +29,7 @@ export async function load({ fetch, parent }) {
const readerFetches: Promise[] = [
api.GET('/api/stats'),
api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }),
- api.GET('/api/documents', {
+ api.GET('/api/documents/search', {
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } }
}),
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } })
@@ -65,7 +65,10 @@ export async function load({ fetch, parent }) {
recentDocsRes?.status === 'fulfilled' &&
(recentDocsRes.value as { response: Response }).response.ok
) {
- recentDocs = ((recentDocsRes.value as { data: unknown }).data as Document[]) ?? [];
+ const searchResult = (recentDocsRes.value as { data: unknown }).data as {
+ items: { document: Document }[];
+ } | null;
+ recentDocs = searchResult?.items.map((i) => i.document) ?? [];
}
if (
recentStoriesRes?.status === 'fulfilled' &&
diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts
index 6603d6c8..1aae2752 100644
--- a/frontend/src/routes/page.server.spec.ts
+++ b/frontend/src/routes/page.server.spec.ts
@@ -281,7 +281,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
expect(transcriptionCalls).toHaveLength(0);
});
- it('calls /api/stats, /api/persons, /api/documents, /api/geschichten for a read-only user', async () => {
+ it('calls /api/stats, /api/persons, /api/documents/search, /api/geschichten for a read-only user', async () => {
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null });
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
@@ -298,7 +298,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string);
expect(calledEndpoints).toContain('/api/stats');
expect(calledEndpoints).toContain('/api/persons');
- expect(calledEndpoints).toContain('/api/documents');
+ expect(calledEndpoints).toContain('/api/documents/search');
expect(calledEndpoints).toContain('/api/geschichten');
});