feat(dashboard): add reader dashboard components

Adds 5 new components for the permission-gated reader layout:
- ReaderStatsStrip: stat tiles (documents / persons / stories) linking to list pages
- ReaderPersonChips: top-N persons by doc count with avatar + name
- ReaderDraftsModule: blog draft list for BLOG_WRITE users
- ReaderRecentDocs: 5 most-recently-updated docs with Neu/Aktualisiert badge
- ReaderRecentStories: 3 latest published stories with 150-char HTML-stripped excerpt

Each component ships with a vitest-browser spec covering the key assertions.
Avatar color/initials logic is inlined to satisfy $lib/shared → $lib/person
boundary rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 21:39:35 +02:00
parent 4cc021b348
commit aabaa78f4d
12 changed files with 555 additions and 4 deletions

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
interface Props {
drafts: Geschichte[];
}
const { drafts }: Props = $props();
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_drafts_heading()}
</h2>
{#if drafts.length === 0}
<p class="font-sans text-sm text-ink-3">{m.dashboard_reader_drafts_empty()}</p>
{:else}
<ul class="flex flex-col gap-2">
{#each drafts as draft (draft.id)}
<li>
<a
href="/geschichten/{draft.id}/edit"
class="flex min-h-[44px] items-center justify-between gap-4 rounded-sm py-2 transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="text-ink-1 truncate font-serif text-sm">{draft.title}</span>
<span class="shrink-0 font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(draft.updatedAt))}
</span>
</a>
</li>
{/each}
</ul>
{/if}
</div>

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderDraftsModule from './ReaderDraftsModule.svelte';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
afterEach(() => {
cleanup();
});
const draft1: Geschichte = {
id: 'g1',
title: 'Mein erster Entwurf',
status: 'DRAFT',
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-02T00:00:00Z'
};
const draft2: Geschichte = {
id: 'g2',
title: 'Zweiter Entwurf',
status: 'DRAFT',
createdAt: '2025-02-01T00:00:00Z',
updatedAt: '2025-02-01T00:00:00Z'
};
describe('ReaderDraftsModule', () => {
it('renders a link to /geschichten/{id}/edit for each draft', async () => {
render(ReaderDraftsModule, { drafts: [draft1, draft2] });
const link1 = page.getByRole('link', { name: /Mein erster Entwurf/ });
await expect.element(link1).toHaveAttribute('href', '/geschichten/g1/edit');
const link2 = page.getByRole('link', { name: /Zweiter Entwurf/ });
await expect.element(link2).toHaveAttribute('href', '/geschichten/g2/edit');
});
it('shows heading "Meine Entwürfe"', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const heading = page.getByRole('heading', { name: /Meine Entwürfe/i });
await expect.element(heading).toBeInTheDocument();
});
it('shows empty state when drafts is empty', async () => {
render(ReaderDraftsModule, { drafts: [] });
const emptyText = page.getByText(/Keine Entwürfe/i);
await expect.element(emptyText).toBeInTheDocument();
});
it('does not show empty state when drafts are present', async () => {
render(ReaderDraftsModule, { drafts: [draft1] });
const emptyText = page.getByText(/Keine Entwürfe/i);
await expect.element(emptyText).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import * as m from '$lib/paraglide/messages.js';
const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const;
function djb2(str: string): number {
let hash = 5381;
for (let i = 0; i < str.length; i++) hash = (hash * 33) ^ str.charCodeAt(i);
return Math.abs(hash);
}
function personAvatarColor(id: string): string {
return AVATAR_PALETTE[djb2(id) % AVATAR_PALETTE.length];
}
function getInitials(name: string): string {
const words = name.trim().split(/\s+/).filter(Boolean);
if (words.length === 0) return '';
if (words.length === 1) return words[0].charAt(0).toUpperCase();
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
}
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
interface Props {
persons: PersonSummaryDTO[];
}
const { persons }: Props = $props();
</script>
<div class="flex flex-col gap-4">
<h2 class="text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_person_chips_heading()}
</h2>
<div class="flex flex-wrap gap-2">
{#each persons as p (p.id)}
<a
href="/persons/{p.id}"
class="flex min-h-[44px] items-center gap-2 rounded-sm border border-line bg-surface px-3 py-2 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
style="background-color: {personAvatarColor(p.id ?? '')}"
>
{getInitials(p.displayName ?? p.lastName ?? '')}
</span>
<span class="flex min-w-0 flex-col">
<span class="text-ink-1 truncate font-serif text-sm">{p.displayName ?? p.lastName}</span>
<span class="font-sans text-xs text-ink-3">{p.documentCount ?? 0} Dok.</span>
</span>
</a>
{/each}
</div>
<a href="/persons" class="self-end font-sans text-sm text-brand-mint hover:underline"
>{m.dashboard_reader_all_persons()}</a
>
</div>

View File

@@ -0,0 +1,66 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderPersonChips from './ReaderPersonChips.svelte';
import type { components } from '$lib/generated/api';
type PersonSummaryDTO = components['schemas']['PersonSummaryDTO'];
afterEach(() => {
cleanup();
});
const person1: PersonSummaryDTO = {
id: 'aaaaaaaa-0000-0000-0000-000000000001',
firstName: 'Anna',
lastName: 'Müller',
displayName: 'Anna Müller',
documentCount: 23,
personType: 'PERSON',
familyMember: false
};
const person2: PersonSummaryDTO = {
id: 'aaaaaaaa-0000-0000-0000-000000000002',
firstName: 'Karl',
lastName: 'Schmidt',
displayName: 'Karl Schmidt',
documentCount: 5,
personType: 'PERSON',
familyMember: false
};
describe('ReaderPersonChips', () => {
it('renders a chip for each person with correct href', async () => {
render(ReaderPersonChips, { persons: [person1, person2] });
const link1 = page.getByRole('link', { name: /Anna Müller/ });
await expect
.element(link1)
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000001');
const link2 = page.getByRole('link', { name: /Karl Schmidt/ });
await expect
.element(link2)
.toHaveAttribute('href', '/persons/aaaaaaaa-0000-0000-0000-000000000002');
});
it('shows document count in each chip', async () => {
render(ReaderPersonChips, { persons: [person1] });
const chip = page.getByRole('link', { name: /Anna Müller/ });
await expect.element(chip).toBeInTheDocument();
const text = ((await chip.element()) as HTMLElement).textContent;
expect(text).toContain('23');
});
it('renders an "Alle Personen" link to /persons', async () => {
render(ReaderPersonChips, { persons: [person1] });
const allLink = page.getByRole('link', { name: /Alle Personen/i });
await expect.element(allLink).toHaveAttribute('href', '/persons');
});
it('renders empty state without chips when persons array is empty', async () => {
render(ReaderPersonChips, { persons: [] });
const chips = page.getByRole('link', { name: /Müller|Schmidt/ });
await expect.element(chips).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Document = components['schemas']['Document'];
interface Props {
documents: Document[];
}
const { documents }: Props = $props();
function isNew(doc: Document): boolean {
return doc.createdAt === doc.updatedAt;
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_recent_docs_heading()}
</h2>
<ul class="flex flex-col divide-y divide-line">
{#each documents as doc (doc.id)}
<li class="py-3 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<a
href="/documents/{doc.id}"
class="text-ink-1 truncate rounded-sm font-serif text-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
{doc.title}
</a>
{#if isNew(doc)}
<span
class="rounded bg-brand-mint/20 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide text-brand-navy uppercase"
>
{m.dashboard_badge_new()}
</span>
{:else}
<span
class="rounded bg-ink-3/10 px-1.5 py-0.5 font-sans text-xs font-bold tracking-wide text-ink-3 uppercase"
>
{m.dashboard_badge_updated()}
</span>
{/if}
</div>
{#if doc.sender}
<a
href="/persons/{doc.sender.id}"
class="font-sans text-xs text-ink-3 transition-colors hover:text-brand-mint"
>
{doc.sender.displayName ?? doc.sender.lastName}
</a>
{/if}
</div>
<span class="shrink-0 font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(doc.updatedAt))}
</span>
</div>
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderRecentDocs from './ReaderRecentDocs.svelte';
import type { components } from '$lib/generated/api';
type Document = components['schemas']['Document'];
afterEach(() => {
cleanup();
});
const baseDoc: Document = {
id: 'doc1',
title: 'Brief an Hans',
originalFilename: 'brief.pdf',
status: 'UPLOADED',
metadataComplete: true,
scriptType: 'HANDWRITING_KURRENT',
createdAt: '2025-01-01T12:00:00Z',
updatedAt: '2025-01-01T12:00:00Z'
};
const updatedDoc: Document = {
...baseDoc,
id: 'doc2',
title: 'Urkunde 1920',
createdAt: '2025-01-01T12:00:00Z',
updatedAt: '2025-03-01T12:00:00Z'
};
describe('ReaderRecentDocs', () => {
it('renders a link to /documents/{id} for each document', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const link = page.getByRole('link', { name: /Brief an Hans/ });
await expect.element(link).toHaveAttribute('href', '/documents/doc1');
});
it('shows "Neu" badge when createdAt equals updatedAt', async () => {
render(ReaderRecentDocs, { documents: [baseDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).toBeInTheDocument();
});
it('shows "Aktualisiert" badge when updatedAt differs from createdAt', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Aktualisiert$/i);
await expect.element(badge).toBeInTheDocument();
});
it('does not show "Neu" badge when updatedAt differs from createdAt', async () => {
render(ReaderRecentDocs, { documents: [updatedDoc] });
const badge = page.getByText(/^Neu$/i);
await expect.element(badge).not.toBeInTheDocument();
});
it('renders sender link when sender is present', async () => {
const docWithSender: Document = {
...baseDoc,
sender: {
id: 'p1',
lastName: 'Müller',
firstName: 'Anna',
displayName: 'Anna Müller',
personType: 'PERSON' as const,
familyMember: false
}
};
render(ReaderRecentDocs, { documents: [docWithSender] });
const senderLink = page.getByRole('link', { name: /Anna Müller/ });
await expect.element(senderLink).toHaveAttribute('href', '/persons/p1');
});
});

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
import { relativeTimeDe } from '$lib/shared/relativeTime';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
interface Props {
stories: Geschichte[];
}
const { stories }: Props = $props();
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, '');
}
function excerpt(body: string | undefined): string {
if (!body) return '';
const text = stripHtml(body);
if (text.length <= 150) return text;
return text.slice(0, 150) + '…';
}
</script>
{#if stories.length > 0}
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-5 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.dashboard_reader_recent_stories_heading()}
</h2>
<ul class="flex flex-col divide-y divide-line">
{#each stories as story (story.id)}
<li class="py-4 first:pt-0 last:pb-0">
<a
href="/geschichten/{story.id}"
class="flex flex-col gap-1 rounded-sm transition-colors hover:text-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="text-ink-1 font-serif text-base italic">{story.title}</span>
{#if story.body}
<p class="line-clamp-2 font-sans text-xs text-ink-3">{excerpt(story.body)}</p>
{/if}
<span class="font-sans text-xs text-ink-3">
{relativeTimeDe(new Date(story.publishedAt ?? story.updatedAt))}
</span>
</a>
</li>
{/each}
</ul>
<a href="/geschichten" class="mt-4 block font-sans text-sm text-brand-mint hover:underline">
{m.dashboard_reader_all_stories()}
</a>
</div>
{/if}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderRecentStories from './ReaderRecentStories.svelte';
import type { components } from '$lib/generated/api';
type Geschichte = components['schemas']['Geschichte'];
afterEach(() => {
cleanup();
});
const story1: Geschichte = {
id: 'g1',
title: 'Die Familie Müller',
body: '<p>Dies ist eine sehr lange Geschichte über die Familie Müller. Sie lebten in Bayern und hatten viele Kinder. Das war früher so üblich in diesen Gebieten.</p>',
status: 'PUBLISHED',
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
publishedAt: '2025-01-01T00:00:00Z'
};
const longBodyStory: Geschichte = {
id: 'g2',
title: 'Sehr lange Geschichte',
body: '<p>' + 'A'.repeat(200) + '</p>',
status: 'PUBLISHED',
createdAt: '2025-02-01T00:00:00Z',
updatedAt: '2025-02-01T00:00:00Z',
publishedAt: '2025-02-01T00:00:00Z'
};
describe('ReaderRecentStories', () => {
it('renders a link to /geschichten/{id} for each story', async () => {
render(ReaderRecentStories, { stories: [story1] });
const link = page.getByRole('link', { name: /Die Familie Müller/ });
await expect.element(link).toHaveAttribute('href', '/geschichten/g1');
});
it('truncates body excerpt to 150 characters and strips HTML', async () => {
render(ReaderRecentStories, { stories: [longBodyStory] });
const excerpt = page.getByText(/A{100,150}/);
await expect.element(excerpt).toBeInTheDocument();
const text = ((await excerpt.element()) as HTMLElement).textContent;
expect(text!.replace(/…$/, '').length).toBeLessThanOrEqual(150);
});
it('shows empty state when stories array is empty', async () => {
render(ReaderRecentStories, { stories: [] });
const links = page.getByRole('link');
await expect.element(links).not.toBeInTheDocument();
});
it('renders "Alle Geschichten" link', async () => {
render(ReaderRecentStories, { stories: [story1] });
const allLink = page.getByRole('link', { name: /Alle Geschichten/i });
await expect.element(allLink).toHaveAttribute('href', '/geschichten');
});
});

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
documents: number | null;
persons: number | null;
stories: number | null;
}
const { documents, persons, stories }: Props = $props();
</script>
<div class="hidden gap-4 sm:flex">
<a
href="/documents"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{documents ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_documents()}</span
>
</a>
<a
href="/persons"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{persons ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_persons()}</span
>
</a>
<a
href="/geschichten"
class="flex min-h-[44px] flex-col items-center gap-1 rounded-sm border border-line bg-surface px-5 py-3 shadow-sm transition-colors hover:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="font-serif text-2xl font-bold text-brand-navy">{stories ?? '—'}</span>
<span class="font-sans text-xs tracking-widest text-ink-3 uppercase"
>{m.dashboard_reader_stats_stories()}</span
>
</a>
</div>

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderStatsStrip from './ReaderStatsStrip.svelte';
afterEach(() => {
cleanup();
});
describe('ReaderStatsStrip', () => {
it('renders a link to /documents', async () => {
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /42/ });
await expect.element(link).toHaveAttribute('href', '/documents');
});
it('renders a link to /persons', async () => {
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /7/ });
await expect.element(link).toHaveAttribute('href', '/persons');
});
it('renders a link to /geschichten', async () => {
render(ReaderStatsStrip, { documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /3/ });
await expect.element(link).toHaveAttribute('href', '/geschichten');
});
it('shows "—" when documents count is null', async () => {
render(ReaderStatsStrip, { documents: null, persons: null, stories: null });
const links = page.getByRole('link');
await expect.element(links.first()).toBeInTheDocument();
const text = ((await links.first().element()) as HTMLElement).textContent;
expect(text).toContain('—');
});
});

View File

@@ -29,7 +29,7 @@ export async function load({ fetch, parent }) {
const readerFetches: Promise<unknown>[] = [
api.GET('/api/stats'),
api.GET('/api/persons', { params: { query: { size: 4, sort: 'documentCount' } } }),
api.GET('/api/documents', {
api.GET('/api/documents/search', {
params: { query: { sort: 'UPDATED_AT', dir: 'DESC', size: 5 } }
}),
api.GET('/api/geschichten', { params: { query: { status: 'PUBLISHED', limit: 3 } } })
@@ -65,7 +65,10 @@ export async function load({ fetch, parent }) {
recentDocsRes?.status === 'fulfilled' &&
(recentDocsRes.value as { response: Response }).response.ok
) {
recentDocs = ((recentDocsRes.value as { data: unknown }).data as Document[]) ?? [];
const searchResult = (recentDocsRes.value as { data: unknown }).data as {
items: { document: Document }[];
} | null;
recentDocs = searchResult?.items.map((i) => i.document) ?? [];
}
if (
recentStoriesRes?.status === 'fulfilled' &&

View File

@@ -281,7 +281,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
expect(transcriptionCalls).toHaveLength(0);
});
it('calls /api/stats, /api/persons, /api/documents, /api/geschichten for a read-only user', async () => {
it('calls /api/stats, /api/persons, /api/documents/search, /api/geschichten for a read-only user', async () => {
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null });
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
typeof createApiClient
@@ -298,7 +298,7 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate
const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0] as string);
expect(calledEndpoints).toContain('/api/stats');
expect(calledEndpoints).toContain('/api/persons');
expect(calledEndpoints).toContain('/api/documents');
expect(calledEndpoints).toContain('/api/documents/search');
expect(calledEndpoints).toContain('/api/geschichten');
});