From 4d9234244e20fc93503f9004071ff7b32b63d855 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 7 May 2026 21:39:35 +0200 Subject: [PATCH] feat(dashboard): add reader dashboard components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../dashboard/ReaderDraftsModule.svelte | 38 ++++++++++ .../ReaderDraftsModule.svelte.spec.ts | 56 ++++++++++++++ .../shared/dashboard/ReaderPersonChips.svelte | 56 ++++++++++++++ .../ReaderPersonChips.svelte.spec.ts | 66 +++++++++++++++++ .../shared/dashboard/ReaderRecentDocs.svelte | 65 ++++++++++++++++ .../dashboard/ReaderRecentDocs.svelte.spec.ts | 74 +++++++++++++++++++ .../dashboard/ReaderRecentStories.svelte | 53 +++++++++++++ .../ReaderRecentStories.svelte.spec.ts | 60 +++++++++++++++ .../shared/dashboard/ReaderStatsStrip.svelte | 43 +++++++++++ .../dashboard/ReaderStatsStrip.svelte.spec.ts | 37 ++++++++++ frontend/src/routes/+page.server.ts | 7 +- frontend/src/routes/page.server.spec.ts | 4 +- 12 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts create mode 100644 frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts create mode 100644 frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts create mode 100644 frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts create mode 100644 frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte create mode 100644 frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte new file mode 100644 index 00000000..a03899e1 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte @@ -0,0 +1,38 @@ + + +
+

+ {m.dashboard_reader_drafts_heading()} +

+ {#if drafts.length === 0} +

{m.dashboard_reader_drafts_empty()}

+ {:else} + + {/if} +
diff --git a/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts new file mode 100644 index 00000000..9d4beab8 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderDraftsModule.svelte.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte new file mode 100644 index 00000000..6cddcfbd --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte @@ -0,0 +1,56 @@ + + + diff --git a/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts new file mode 100644 index 00000000..a24da959 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderPersonChips.svelte.spec.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte new file mode 100644 index 00000000..3fd37db4 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte @@ -0,0 +1,65 @@ + + +
+

+ {m.dashboard_reader_recent_docs_heading()} +

+ +
diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts new file mode 100644 index 00000000..6d0f45f7 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentDocs.svelte.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte new file mode 100644 index 00000000..8c19b8ae --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte @@ -0,0 +1,53 @@ + + +{#if stories.length > 0} + +{/if} diff --git a/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts new file mode 100644 index 00000000..df7f3891 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderRecentStories.svelte.spec.ts @@ -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: '

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.

', + 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: '

' + 'A'.repeat(200) + '

', + 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'); + }); +}); diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte new file mode 100644 index 00000000..8129b03c --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte @@ -0,0 +1,43 @@ + + + diff --git a/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts new file mode 100644 index 00000000..b33ddfc7 --- /dev/null +++ b/frontend/src/lib/shared/dashboard/ReaderStatsStrip.svelte.spec.ts @@ -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('—'); + }); +}); diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 9643fa85..57dc7c54 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -29,7 +29,7 @@ export async function load({ fetch, parent }) { const readerFetches: Promise[] = [ 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' && diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index 6603d6c8..1aae2752 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -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'); });