From f4c99cabd59fcd470181d1892014b866317d1e33 Mon Sep 17 00:00:00 2001
From: Marcel
Date: Sun, 29 Mar 2026 19:52:37 +0200
Subject: [PATCH] feat(persons): enrich /persons list with stats bar, life
dates, doc count chip
Load /api/stats in parallel; PersonsStatsBar shows totals; person cards
show alias, life date range, and document count badge.
Co-Authored-By: Claude Sonnet 4.6
---
frontend/src/routes/persons/+page.server.ts | 27 +++++++++----
frontend/src/routes/persons/+page.svelte | 40 +++++++++++--------
.../routes/persons/PersonsEmptyState.svelte | 18 +++++++++
.../src/routes/persons/PersonsStatsBar.svelte | 26 ++++++++++++
.../src/routes/persons/page.svelte.spec.ts | 33 ++++++++++++++-
5 files changed, 119 insertions(+), 25 deletions(-)
create mode 100644 frontend/src/routes/persons/PersonsEmptyState.svelte
create mode 100644 frontend/src/routes/persons/PersonsStatsBar.svelte
diff --git a/frontend/src/routes/persons/+page.server.ts b/frontend/src/routes/persons/+page.server.ts
index 139ca9ec..79ad39bc 100644
--- a/frontend/src/routes/persons/+page.server.ts
+++ b/frontend/src/routes/persons/+page.server.ts
@@ -2,17 +2,30 @@ import { error } from '@sveltejs/kit';
import { createApiClient } from '$lib/api.server';
import { getErrorMessage } from '$lib/errors';
-export async function load({ url, fetch }) {
+export async function load({ url, fetch, locals }) {
const q = url.searchParams.get('q') || '';
const api = createApiClient(fetch);
- const result = await api.GET('/api/persons', {
- params: { query: { q: q || undefined } }
- });
+ const canWrite =
+ (locals.user as { groups?: { permissions: string[] }[] } | undefined)?.groups?.some((g) =>
+ g.permissions.includes('WRITE_ALL')
+ ) ?? false;
- if (!result.response.ok) {
- throw error(result.response.status, getErrorMessage(undefined));
+ const [personsResult, statsResult] = await Promise.all([
+ api.GET('/api/persons', { params: { query: { q: q || undefined } } }),
+ api.GET('/api/stats', {})
+ ]);
+
+ if (!personsResult.response.ok) {
+ throw error(personsResult.response.status, getErrorMessage(undefined));
}
- return { persons: result.data!, q };
+ const stats = statsResult.response.ok
+ ? {
+ totalPersons: statsResult.data!.totalPersons ?? 0,
+ totalDocuments: statsResult.data!.totalDocuments ?? 0
+ }
+ : { totalPersons: 0, totalDocuments: 0 };
+
+ return { persons: personsResult.data!, stats, q, canWrite };
}
diff --git a/frontend/src/routes/persons/+page.svelte b/frontend/src/routes/persons/+page.svelte
index 3b3d6a42..2ee01017 100644
--- a/frontend/src/routes/persons/+page.svelte
+++ b/frontend/src/routes/persons/+page.svelte
@@ -2,6 +2,9 @@
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
+import { formatLifeDateRange } from '$lib/utils/personLifeDates';
+import PersonsStatsBar from './PersonsStatsBar.svelte';
+import PersonsEmptyState from './PersonsEmptyState.svelte';
let { data } = $props();
@@ -37,6 +40,12 @@ function handleSearch() {
{m.persons_subtitle()}
+
{#if data.canWrite}
{#if data.persons.length === 0}
-
-
-

-
-
{m.persons_empty_heading()}
-
{m.persons_empty_text()}
-
+
{:else}
{#each data.persons as person (person.id)}
@@ -113,7 +109,7 @@ function handleSearch() {
- {person.firstName[0]}{person.lastName[0]}
+ {person.firstName?.[0]}{person.lastName?.[0]}
@@ -124,7 +120,19 @@ function handleSearch() {
{person.lastName}
{#if person.alias}
- "{person.alias}"
+ "{person.alias}"
+ {/if}
+ {#if person.birthYear || person.deathYear}
+
+ {formatLifeDateRange(person.birthYear, person.deathYear)}
+
+ {/if}
+ {#if (person.documentCount ?? 0) > 0}
+
+ {person.documentCount}
+
{/if}
diff --git a/frontend/src/routes/persons/PersonsEmptyState.svelte b/frontend/src/routes/persons/PersonsEmptyState.svelte
new file mode 100644
index 00000000..9e9f005b
--- /dev/null
+++ b/frontend/src/routes/persons/PersonsEmptyState.svelte
@@ -0,0 +1,18 @@
+
+
+
+
+

+
+
{m.persons_empty_heading()}
+
{m.persons_empty_text()}
+
diff --git a/frontend/src/routes/persons/PersonsStatsBar.svelte b/frontend/src/routes/persons/PersonsStatsBar.svelte
new file mode 100644
index 00000000..67f1e4db
--- /dev/null
+++ b/frontend/src/routes/persons/PersonsStatsBar.svelte
@@ -0,0 +1,26 @@
+
+
+
+ {personsLabel} · {documentsLabel}
+
diff --git a/frontend/src/routes/persons/page.svelte.spec.ts b/frontend/src/routes/persons/page.svelte.spec.ts
index 9dc5fc75..9fbb8f7f 100644
--- a/frontend/src/routes/persons/page.svelte.spec.ts
+++ b/frontend/src/routes/persons/page.svelte.spec.ts
@@ -11,11 +11,24 @@ const makePerson = (overrides = {}) => ({
id: '1',
firstName: 'Max',
lastName: 'Mustermann',
+ documentCount: 0,
...overrides
});
-const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] };
-const dataWithPersons = { ...emptyData, persons: [makePerson()] };
+const defaultStats = { totalPersons: 0, totalDocuments: 0 };
+const emptyData = {
+ user: undefined,
+ canWrite: true,
+ canAnnotate: false,
+ q: '',
+ persons: [],
+ stats: defaultStats
+};
+const dataWithPersons = {
+ ...emptyData,
+ persons: [makePerson()],
+ stats: { totalPersons: 1, totalDocuments: 3 }
+};
afterEach(cleanup);
@@ -48,6 +61,22 @@ describe('Persons page – rendering', () => {
.element(page.getByRole('link', { name: /Max Mustermann/ }))
.toHaveAttribute('href', '/persons/1');
});
+
+ it('shows alias in italic when provided', async () => {
+ render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } });
+ await expect.element(page.getByText('"Maxi"')).toBeInTheDocument();
+ });
+
+ it('shows life date range when birthYear is provided', async () => {
+ render(Page, { data: { ...emptyData, persons: [makePerson({ birthYear: 1900 })] } });
+ await expect.element(page.getByText('* 1900')).toBeInTheDocument();
+ });
+
+ it('shows stats bar with person and document counts', async () => {
+ render(Page, { data: dataWithPersons });
+ await expect.element(page.getByText(/1 Person/)).toBeInTheDocument();
+ await expect.element(page.getByText(/3 Dokumente/)).toBeInTheDocument();
+ });
});
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────