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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-29 19:52:37 +02:00
parent 3abdf9bb68
commit f4c99cabd5
5 changed files with 119 additions and 25 deletions

View File

@@ -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 };
}

View File

@@ -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() {
<p class="mt-2 max-w-xl font-sans text-sm text-ink-2">
{m.persons_subtitle()}
</p>
<div class="mt-1">
<PersonsStatsBar
totalPersons={data.stats.totalPersons ?? 0}
totalDocuments={data.stats.totalDocuments ?? 0}
/>
</div>
{#if data.canWrite}
<a
href="/persons/new"
@@ -82,20 +91,7 @@ function handleSearch() {
</div>
{#if data.persons.length === 0}
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
>
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-ink">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
<p class="font-serif text-lg text-ink">{m.persons_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-ink-2">{m.persons_empty_text()}</p>
</div>
<PersonsEmptyState />
{:else}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each data.persons as person (person.id)}
@@ -113,7 +109,7 @@ function handleSearch() {
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-primary font-serif text-lg text-primary-fg transition-colors group-hover:bg-accent group-hover:text-ink"
>
{person.firstName[0]}{person.lastName[0]}
{person.firstName?.[0]}{person.lastName?.[0]}
</div>
</div>
@@ -124,7 +120,19 @@ function handleSearch() {
{person.lastName}
</p>
{#if person.alias}
<p class="mt-0.5 truncate font-sans text-xs text-ink-2">"{person.alias}"</p>
<p class="mt-0.5 truncate font-sans text-xs text-ink-2 italic">"{person.alias}"</p>
{/if}
{#if person.birthYear || person.deathYear}
<p class="mt-0.5 font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</p>
{/if}
{#if (person.documentCount ?? 0) > 0}
<span
class="mt-1.5 inline-flex items-center rounded-full bg-primary px-2 py-0.5 font-sans text-[10px] font-bold text-primary-fg"
>
{person.documentCount}
</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
</script>
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-line bg-surface py-16 text-center"
>
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-muted text-ink">
<img
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Account-MD.svg"
alt=""
aria-hidden="true"
class="h-6 w-6"
/>
</div>
<p class="font-serif text-lg text-ink">{m.persons_empty_heading()}</p>
<p class="mt-1 font-sans text-sm text-ink-2">{m.persons_empty_text()}</p>
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
let {
totalPersons,
totalDocuments
}: {
totalPersons: number;
totalDocuments: number;
} = $props();
const personsLabel = $derived(
totalPersons === 1
? m.persons_stats_persons_one()
: m.persons_stats_persons_many({ count: totalPersons })
);
const documentsLabel = $derived(
totalDocuments === 1
? m.persons_stats_documents_one()
: m.persons_stats_documents_many({ count: totalDocuments })
);
</script>
<p class="font-sans text-sm text-ink-2">
{personsLabel} · {documentsLabel}
</p>

View File

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