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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
18
frontend/src/routes/persons/PersonsEmptyState.svelte
Normal file
18
frontend/src/routes/persons/PersonsEmptyState.svelte
Normal 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>
|
||||
26
frontend/src/routes/persons/PersonsStatsBar.svelte
Normal file
26
frontend/src/routes/persons/PersonsStatsBar.svelte
Normal 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>
|
||||
@@ -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) ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user