diff --git a/frontend/.gitignore b/frontend/.gitignore
index 3f9bc1b0..5b5d80b6 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -31,3 +31,6 @@ src/lib/paraglide
# src/lib/generated/api.ts
src/lib/paraglide_bak*
/coverage
+
+# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
+e2e/.auth/
diff --git a/frontend/e2e/dashboard-classic-split.spec.ts b/frontend/e2e/dashboard-classic-split.spec.ts
new file mode 100644
index 00000000..203a67a2
--- /dev/null
+++ b/frontend/e2e/dashboard-classic-split.spec.ts
@@ -0,0 +1,29 @@
+import { test, expect } from '@playwright/test';
+import { login } from './helpers/auth';
+
+/**
+ * Classic Split layout — verifies the right column visibility guard.
+ *
+ * The right column (DropZone + NeedsMetadata queue) is only rendered when
+ * `canWrite === true` or there are incomplete docs. A read-only user with a
+ * complete archive must never see an empty 300px ghost column.
+ */
+
+test.describe('Dashboard Classic Split — write user', () => {
+ test('right column is visible for admin user', async ({ page }) => {
+ await page.goto('/');
+ await expect(page.getByTestId('dashboard-right-column')).toBeVisible();
+ });
+});
+
+test.describe('Dashboard Classic Split — read-only user', () => {
+ test.use({ storageState: { cookies: [], origins: [] } });
+
+ test.beforeEach(async ({ page }) => {
+ await login(page, 'reader', 'reader123');
+ });
+
+ test('right column is absent for read-only user with no incomplete docs', async ({ page }) => {
+ await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible();
+ });
+});
diff --git a/frontend/e2e/password-reset.spec.ts b/frontend/e2e/password-reset.spec.ts
index d2dd5366..fc864820 100644
--- a/frontend/e2e/password-reset.spec.ts
+++ b/frontend/e2e/password-reset.spec.ts
@@ -80,8 +80,7 @@ test.describe('Password reset', () => {
await page.locator('input[name="currentPassword"]').fill(newPassword);
await page.locator('input[name="newPassword"]').fill(originalPassword);
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
- // Profile page has two "Speichern" buttons — the password form is the last one
- await page.locator('button[type="submit"]').last().click();
+ await page.getByTestId('submit-password').click();
// After changing password, auth_token is stale → redirect to login
await expect(page).toHaveURL(/\/login/);
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 5017ad9a..3024029b 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -375,6 +375,8 @@
"dashboard_needs_metadata_heading": "Metadaten fehlen",
"dashboard_needs_metadata_show_all": "Alle anzeigen",
"dashboard_recent_heading": "Zuletzt aktiv",
+ "dashboard_stats_documents": "Dokumente",
+ "dashboard_stats_persons": "Personen",
"dashboard_resume_label": "Zuletzt geöffnet:",
"dashboard_resume_fallback": "Unbekanntes Dokument",
"doc_status_placeholder": "Platzhalter",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 6b3e157c..109e7060 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -375,6 +375,8 @@
"dashboard_needs_metadata_heading": "Missing Metadata",
"dashboard_needs_metadata_show_all": "Show all",
"dashboard_recent_heading": "Recent Activity",
+ "dashboard_stats_documents": "Documents",
+ "dashboard_stats_persons": "Persons",
"dashboard_resume_label": "Last opened:",
"dashboard_resume_fallback": "Unknown document",
"doc_status_placeholder": "Placeholder",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 0b7a9e36..c9b445c6 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -375,6 +375,8 @@
"dashboard_needs_metadata_heading": "Metadatos incompletos",
"dashboard_needs_metadata_show_all": "Ver todos",
"dashboard_recent_heading": "Actividad reciente",
+ "dashboard_stats_documents": "Documentos",
+ "dashboard_stats_persons": "Personas",
"dashboard_resume_label": "Último abierto:",
"dashboard_resume_fallback": "Documento desconocido",
"doc_status_placeholder": "Marcador",
diff --git a/frontend/src/lib/components/DashboardMentions.svelte b/frontend/src/lib/components/DashboardMentions.svelte
deleted file mode 100644
index 176d5fa3..00000000
--- a/frontend/src/lib/components/DashboardMentions.svelte
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-{#if mentions.length > 0}
-
-
- {m.dashboard_notifications_heading()}
-
-
- {#each mentions as mention (mention.id)}
-
- {#if mention.documentId}
-
{mention.actorName ?? ''}
-
- {mention.type === 'MENTION'
- ? m.dashboard_notification_mentioned()
- : m.dashboard_notification_replied()}
-
- {:else}
-
{mention.actorName ?? ''}
- {/if}
-
- {/each}
-
-
-
-{/if}
diff --git a/frontend/src/lib/components/DashboardMentions.svelte.spec.ts b/frontend/src/lib/components/DashboardMentions.svelte.spec.ts
deleted file mode 100644
index c0dd67df..00000000
--- a/frontend/src/lib/components/DashboardMentions.svelte.spec.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { describe, it, expect, afterEach } from 'vitest';
-import { cleanup, render } from 'vitest-browser-svelte';
-import { page } from 'vitest/browser';
-
-import DashboardMentions from './DashboardMentions.svelte';
-
-afterEach(cleanup);
-
-type NotificationDTO = {
- id: string;
- type: 'REPLY' | 'MENTION';
- documentId?: string;
- referenceId?: string;
- annotationId?: string;
- read: boolean;
- createdAt: string;
- actorName?: string;
-};
-
-function makeMention(overrides: Partial = {}): NotificationDTO {
- return {
- id: 'notif-1',
- type: 'MENTION',
- documentId: 'doc-abc',
- referenceId: 'comment-xyz',
- read: false,
- createdAt: '2026-01-15T10:00:00Z',
- actorName: 'Anna Schmidt',
- ...overrides
- };
-}
-
-describe('DashboardMentions', () => {
- it('renders nothing when mentions list is empty', async () => {
- render(DashboardMentions, { mentions: [] });
- const widget = page.getByTestId('dashboard-mentions');
- await expect.element(widget).not.toBeInTheDocument();
- });
-
- it('shows a heading when mentions are present', async () => {
- render(DashboardMentions, { mentions: [makeMention()] });
- const widget = page.getByTestId('dashboard-mentions');
- await expect.element(widget).toBeInTheDocument();
- });
-
- it('builds link with commentId param when no annotationId', async () => {
- render(DashboardMentions, {
- mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
- });
- const link = page.getByRole('link');
- await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
- });
-
- it('builds link with commentId and annotationId when annotationId is present', async () => {
- render(DashboardMentions, {
- mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
- });
- const link = page.getByRole('link');
- await expect
- .element(link)
- .toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
- });
-
- it('shows actor name in each row', async () => {
- render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
- await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
- });
-
- it('shows "replied" label for REPLY type', async () => {
- render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] });
- const widget = page.getByTestId('dashboard-mentions');
- await expect.element(widget).toBeInTheDocument();
- const link = page.getByRole('link');
- await expect.element(link).toBeInTheDocument();
- });
-
- it('renders a span instead of a link when documentId is absent', async () => {
- render(DashboardMentions, {
- mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })]
- });
- await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument();
- const links = page.getByRole('link');
- await expect.element(links).not.toBeInTheDocument();
- });
-});
diff --git a/frontend/src/lib/components/DashboardRecentDocuments.svelte b/frontend/src/lib/components/DashboardRecentDocuments.svelte
index bea58716..bfe10e0f 100644
--- a/frontend/src/lib/components/DashboardRecentDocuments.svelte
+++ b/frontend/src/lib/components/DashboardRecentDocuments.svelte
@@ -1,6 +1,7 @@
@@ -94,20 +98,25 @@ $effect(() => {
{#if data.isDashboard}
- {#if data.canWrite}
-
-
-
- {/if}
+
+
+
+ {#if showRightColumn}
+
+ {#if data.canWrite}
+
+ {/if}
+
+
+
+
+
+ {/if}
-
-
-
-
-
-
+
{:else}
diff --git a/frontend/src/routes/admin/groups/layout.svelte.spec.ts b/frontend/src/routes/admin/groups/layout.svelte.spec.ts
index 05c48a81..68a35cf5 100644
--- a/frontend/src/routes/admin/groups/layout.svelte.spec.ts
+++ b/frontend/src/routes/admin/groups/layout.svelte.spec.ts
@@ -109,10 +109,12 @@ describe('GroupsListPanel — collapse toggle', () => {
});
it('persists collapse state using the groups-specific localStorage key', async () => {
- const setSpy = vi.spyOn(Storage.prototype, 'setItem');
render(GroupsListPanel, { groups });
+ const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector
('[aria-label="Liste einklappen"]')!.click();
- expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true');
+ await vi.waitFor(() =>
+ expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true')
+ );
setSpy.mockRestore();
});
});
diff --git a/frontend/src/routes/admin/tags/layout.svelte.spec.ts b/frontend/src/routes/admin/tags/layout.svelte.spec.ts
index 8bf7eb61..c3edbbad 100644
--- a/frontend/src/routes/admin/tags/layout.svelte.spec.ts
+++ b/frontend/src/routes/admin/tags/layout.svelte.spec.ts
@@ -88,10 +88,12 @@ describe('TagsListPanel — collapse toggle', () => {
});
it('persists collapse state using the tags-specific localStorage key', async () => {
- const setSpy = vi.spyOn(Storage.prototype, 'setItem');
render(TagsListPanel, { tags });
+ const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector('[aria-label="Liste einklappen"]')!.click();
- expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true');
+ await vi.waitFor(() =>
+ expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true')
+ );
setSpy.mockRestore();
});
});
diff --git a/frontend/src/routes/admin/users/layout.svelte.spec.ts b/frontend/src/routes/admin/users/layout.svelte.spec.ts
index 865ddbe6..84164697 100644
--- a/frontend/src/routes/admin/users/layout.svelte.spec.ts
+++ b/frontend/src/routes/admin/users/layout.svelte.spec.ts
@@ -131,10 +131,12 @@ describe('UsersListPanel — collapse toggle', () => {
});
it('persists collapse state using the users-specific localStorage key', async () => {
- const setSpy = vi.spyOn(Storage.prototype, 'setItem');
render(UsersListPanel, { users });
+ const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector('[aria-label="Liste einklappen"]')!.click();
- expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true');
+ await vi.waitFor(() =>
+ expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true')
+ );
setSpy.mockRestore();
});
});
diff --git a/frontend/src/routes/conversations/page.svelte.spec.ts b/frontend/src/routes/conversations/page.svelte.spec.ts
index 85f18e63..ebc7c4b3 100644
--- a/frontend/src/routes/conversations/page.svelte.spec.ts
+++ b/frontend/src/routes/conversations/page.svelte.spec.ts
@@ -48,9 +48,9 @@ const withDocs = {
// ─── Empty state ──────────────────────────────────────────────────────────────
describe('Conversations page – empty state', () => {
- it('shows the "select two persons" prompt when no persons are selected', async () => {
+ it('shows the empty-state heading when no persons are selected', async () => {
render(Page, { data: baseData });
- await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
+ await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
});
it('hides the swap button when no persons are selected', async () => {
diff --git a/frontend/src/routes/login/page.svelte.spec.ts b/frontend/src/routes/login/page.svelte.spec.ts
index 02b4d5e0..6cf420a8 100644
--- a/frontend/src/routes/login/page.svelte.spec.ts
+++ b/frontend/src/routes/login/page.svelte.spec.ts
@@ -10,7 +10,9 @@ afterEach(cleanup);
describe('Login page – rendering', () => {
it('renders the page title', async () => {
render(LoginPage, {});
- await expect.element(page.getByRole('link', { name: 'Familienarchiv' })).toBeInTheDocument();
+ await expect
+ .element(page.getByRole('link', { name: 'Familienarchiv' }).first())
+ .toBeInTheDocument();
await page.screenshot({ path: 'test-results/screenshots/login-default.png' });
});
diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts
index 63d7abd6..24b2e0ad 100644
--- a/frontend/src/routes/page.server.spec.ts
+++ b/frontend/src/routes/page.server.spec.ts
@@ -22,14 +22,14 @@ function makeUrl(params: Record = {}) {
// ─── dashboard mode (no search filters) ──────────────────────────────────────
describe('home page load — dashboard mode', () => {
- it('sets isDashboard true and fetches all three widget APIs', async () => {
+ it('sets isDashboard true and fetches stats, incomplete, and recent APIs', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
.mockResolvedValueOnce({
response: { ok: true },
- data: { content: [{ id: 'n1' }] }
- }) // notifications
+ data: { totalDocuments: 42, totalPersons: 7 }
+ }) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
@@ -39,17 +39,20 @@ describe('home page load — dashboard mode', () => {
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
expect(result.isDashboard).toBe(true);
- expect(result.mentions).toHaveLength(1);
+ expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
expect(result.incompleteDocs).toHaveLength(1);
expect(result.recentDocs).toHaveLength(1);
expect(result.documents).toEqual([]);
});
- it('defaults mentions to [] when notifications API rejects', async () => {
+ it('returns stats with totalDocuments from /api/stats', async () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
- .mockRejectedValueOnce(new Error('network')) // notifications
+ .mockResolvedValueOnce({
+ response: { ok: true },
+ data: { totalDocuments: 248, totalPersons: 34 }
+ }) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
@@ -58,7 +61,24 @@ describe('home page load — dashboard mode', () => {
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
- expect(result.mentions).toEqual([]);
+ expect(result.stats?.totalDocuments).toBe(248);
+ expect(result.stats?.totalPersons).toBe(34);
+ });
+
+ it('returns stats: null when /api/stats rejects', async () => {
+ const mockGet = vi
+ .fn()
+ .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
+ .mockRejectedValueOnce(new Error('network')) // stats
+ .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
+ .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
+ vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
+ typeof createApiClient
+ >);
+
+ const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
+
+ expect(result.stats).toBeNull();
});
it('defaults incompleteDocs to [] when incomplete API rejects', async () => {
@@ -81,7 +101,10 @@ describe('home page load — dashboard mode', () => {
const mockGet = vi
.fn()
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
- .mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
+ .mockResolvedValueOnce({
+ response: { ok: true },
+ data: { totalDocuments: 0, totalPersons: 0 }
+ }) // stats
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
.mockRejectedValueOnce(new Error('network')); // recent
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
@@ -113,7 +136,7 @@ describe('home page load — search mode', () => {
expect(result.isDashboard).toBe(false);
expect(result.documents).toHaveLength(1);
- expect(result.mentions).toEqual([]);
+ expect(result.stats).toBeNull();
expect(result.incompleteDocs).toEqual([]);
expect(result.recentDocs).toEqual([]);
// Only two API calls — no widget calls
diff --git a/frontend/src/routes/page.svelte.spec.ts b/frontend/src/routes/page.svelte.spec.ts
index 09956fc7..8a280c1a 100644
--- a/frontend/src/routes/page.svelte.spec.ts
+++ b/frontend/src/routes/page.svelte.spec.ts
@@ -21,8 +21,12 @@ const emptyData = {
user: undefined,
canWrite: true,
canAnnotate: false,
+ isDashboard: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [],
+ incompleteDocs: [],
+ recentDocs: [],
+ stats: null,
incompleteCount: 0,
initialValues: { senderName: '', receiverName: '' },
error: null
@@ -189,6 +193,42 @@ describe('Home page – search input keystroke preservation', () => {
});
});
+// ─── Dashboard mode ───────────────────────────────────────────────────────────
+
+describe('Home page – dashboard mode', () => {
+ const dashboardData = {
+ ...emptyData,
+ isDashboard: true,
+ canWrite: false,
+ incompleteDocs: [],
+ recentDocs: []
+ };
+
+ it('hides the right column when canWrite is false and incompleteDocs is empty', async () => {
+ render(Page, { data: dashboardData });
+ const rightCol = page.getByTestId('dashboard-right-column');
+ await expect.element(rightCol).not.toBeInTheDocument();
+ });
+
+ it('shows the right column when canWrite is true', async () => {
+ render(Page, { data: { ...dashboardData, canWrite: true } });
+ const rightCol = page.getByTestId('dashboard-right-column');
+ await expect.element(rightCol).toBeInTheDocument();
+ });
+
+ it('shows the right column when incompleteDocs is non-empty', async () => {
+ render(Page, {
+ data: {
+ ...dashboardData,
+ canWrite: false,
+ incompleteDocs: [{ id: 'd1', title: 'Taufschein' }]
+ }
+ });
+ const rightCol = page.getByTestId('dashboard-right-column');
+ await expect.element(rightCol).toBeInTheDocument();
+ });
+});
+
// ─── Error state ──────────────────────────────────────────────────────────────
describe('Home page – error state', () => {
diff --git a/frontend/src/routes/persons/page.svelte.spec.ts b/frontend/src/routes/persons/page.svelte.spec.ts
index 9fbb8f7f..113df55d 100644
--- a/frontend/src/routes/persons/page.svelte.spec.ts
+++ b/frontend/src/routes/persons/page.svelte.spec.ts
@@ -64,7 +64,7 @@ describe('Persons page – rendering', () => {
it('shows alias in italic when provided', async () => {
render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } });
- await expect.element(page.getByText('"Maxi"')).toBeInTheDocument();
+ await expect.element(page.getByText('„Maxi"')).toBeInTheDocument();
});
it('shows life date range when birthYear is provided', async () => {
diff --git a/frontend/src/routes/profile/PasswordChangeForm.svelte b/frontend/src/routes/profile/PasswordChangeForm.svelte
index 32145da3..6c7e0b42 100644
--- a/frontend/src/routes/profile/PasswordChangeForm.svelte
+++ b/frontend/src/routes/profile/PasswordChangeForm.svelte
@@ -70,6 +70,7 @@ let {