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) ──────────────────────────────────────