From 5a98edac86443a647b66ba2a212ceabb18e015e3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 19 Apr 2026 17:44:08 +0200 Subject: [PATCH] feat(dashboard): complete frontend redesign for Issue #271 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - +layout.svelte: Upload button in header (authenticated users only) - +page.server.ts: call /api/dashboard/resume, /pulse, /activity; remove deprecated /api/documents/incomplete and /recent-activity - +page.svelte: 2-col grid layout (main + 320px sidebar), greeting, DashboardFamilyPulse + DashboardActivityFeed in sidebar - DashboardResumeStrip: refactored to use server data (resumeDoc prop), SVG thumbnail, progress bar with aria-*, empty state, CTA - DashboardFamilyPulse: new component — weekly stats from audit_log - DashboardActivityFeed: new component — activity feed with "für dich" badge - Update specs for new data shapes Co-Authored-By: Claude Sonnet 4.6 --- .../components/DashboardActivityFeed.svelte | 81 +++++++++++ .../DashboardActivityFeed.svelte.spec.ts | 42 ++++++ .../components/DashboardFamilyPulse.svelte | 70 ++++++++++ .../components/DashboardResumeStrip.svelte | 128 ++++++++++++++---- .../DashboardResumeStrip.svelte.spec.ts | 68 +++++----- frontend/src/routes/+layout.svelte | 28 +++- frontend/src/routes/+page.server.ts | 45 +++--- frontend/src/routes/+page.svelte | 72 +++++----- frontend/src/routes/page.server.spec.ts | 63 ++++++--- frontend/src/routes/page.svelte.spec.ts | 48 ++++--- 10 files changed, 488 insertions(+), 157 deletions(-) create mode 100644 frontend/src/lib/components/DashboardActivityFeed.svelte create mode 100644 frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts create mode 100644 frontend/src/lib/components/DashboardFamilyPulse.svelte diff --git a/frontend/src/lib/components/DashboardActivityFeed.svelte b/frontend/src/lib/components/DashboardActivityFeed.svelte new file mode 100644 index 00000000..f11691b0 --- /dev/null +++ b/frontend/src/lib/components/DashboardActivityFeed.svelte @@ -0,0 +1,81 @@ + + +
+
+

+ {m.feed_caption()} +

+ Alle → +
+ + {#if feed.length > 0} +
    + {#each feed as item (item.happenedAt + item.documentId + item.kind)} +
  • + {#if item.actor} + {item.actor.initials} + {:else} + ? + {/if} + +
    +

    + {#if item.actor} + {item.actor.name ?? item.actor.initials} + {/if} + {verb(item.kind)} + + {item.documentTitle} + + {#if item.youMentioned} + + {m.feed_for_you()} + + {/if} +

    +

    {formatDate(item.happenedAt)}

    +
    +
  • + {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts b/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts new file mode 100644 index 00000000..b12c682c --- /dev/null +++ b/frontend/src/lib/components/DashboardActivityFeed.svelte.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import DashboardActivityFeed from './DashboardActivityFeed.svelte'; +import type { components } from '$lib/generated/api'; + +type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; + +afterEach(() => { + cleanup(); +}); + +const baseItem: ActivityFeedItemDTO = { + kind: 'TEXT_SAVED', + actor: { initials: 'MR', color: '#7a4f9a', name: 'Max Raddatz' }, + documentId: 'doc-1', + documentTitle: 'Brief 1920', + happenedAt: '2026-04-19T10:00:00Z', + youMentioned: false +}; + +describe('DashboardActivityFeed', () => { + it('renders "für dich" badge when youMentioned is true', async () => { + const item: ActivityFeedItemDTO = { ...baseItem, kind: 'MENTION_CREATED', youMentioned: true }; + render(DashboardActivityFeed, { feed: [item] }); + const badge = page.getByText('für dich'); + await expect.element(badge).toBeInTheDocument(); + }); + + it('does not render "für dich" badge when youMentioned is false', async () => { + render(DashboardActivityFeed, { feed: [baseItem] }); + const badge = page.getByText('für dich'); + await expect.element(badge).not.toBeInTheDocument(); + }); + + it('renders empty state when feed is empty', async () => { + render(DashboardActivityFeed, { feed: [] }); + const section = page.getByText('Kommentare & Aktivität'); + await expect.element(section).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/components/DashboardFamilyPulse.svelte b/frontend/src/lib/components/DashboardFamilyPulse.svelte new file mode 100644 index 00000000..20c50268 --- /dev/null +++ b/frontend/src/lib/components/DashboardFamilyPulse.svelte @@ -0,0 +1,70 @@ + + +{#if pulse !== null} +
+

+ {m.pulse_eyebrow()} +

+ + {#if pulse.pages > 0} +

+ Ihr habt {pulse.pages} Seiten bearbeitet. +

+ {/if} + + {#if pulse.yourPages > 0} +

+ Du selbst hast {pulse.yourPages} davon bearbeitet. +

+ {/if} + + {#if pulse.contributors.length > 0} +
+

{m.pulse_contributors()}

+ {#each pulse.contributors as c (c.initials)} + {c.initials} + {/each} +
+ {/if} + +
+
+ {pulse.annotated} + + {m.pulse_transcribed()} + +
+
+ {pulse.transcribed} + + {m.pulse_reviewed()} + +
+
+ {pulse.uploaded} + + {m.pulse_uploaded()} + +
+
+
+{/if} diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte b/frontend/src/lib/components/DashboardResumeStrip.svelte index 1e7e9e31..ef18cb0d 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte @@ -1,37 +1,115 @@ -{#if lastVisited} +{#if resumeDoc === null} +{:else} +
+ + +
+

+ + {m.dashboard_resume_label()} + · + {m.dashboard_page_of({ page: resumeDoc.page, pages: resumeDoc.pages })} +

+ +

{resumeDoc.title}

+ +

{resumeDoc.caption}

+ +
+ {resumeDoc.excerpt} +
+ +
+ {resumeDoc.pct}% +
+
+
+ {#each resumeDoc.collaborators.slice(0, 3) as collab (collab.initials)} + {collab.initials} + {/each} +
+ + +
+
{/if} diff --git a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts index 2fac46b0..ec241bf5 100644 --- a/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts +++ b/frontend/src/lib/components/DashboardResumeStrip.svelte.spec.ts @@ -3,48 +3,48 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import DashboardResumeStrip from './DashboardResumeStrip.svelte'; +import type { components } from '$lib/generated/api'; + +type DashboardResumeDTO = components['schemas']['DashboardResumeDTO']; afterEach(() => { cleanup(); - localStorage.clear(); }); +const mockResume: DashboardResumeDTO = { + documentId: 'doc-123', + title: 'Geburtsurkunde 1920', + caption: 'Max Mustermann · 1920-01-01', + excerpt: 'Hiermit wird beurkundet…', + page: 1, + pages: 4, + pct: 75, + collaborators: [] +}; + describe('DashboardResumeStrip', () => { - it('renders nothing when no last-visited document in localStorage', async () => { - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).not.toBeInTheDocument(); + it('renders empty state heading when resumeDoc is null', async () => { + render(DashboardResumeStrip, { resumeDoc: null }); + const heading = page.getByRole('heading', { name: /Noch kein Dokument begonnen/i }); + await expect.element(heading).toBeInTheDocument(); }); - it('shows the strip with link when localStorage has a document', async () => { - localStorage.setItem( - 'familienarchiv.lastVisited', - JSON.stringify({ id: 'doc-123', title: 'Geburtsurkunde 1920' }) - ); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).toBeInTheDocument(); - const link = page.getByRole('link', { name: /Geburtsurkunde 1920/ }); - await expect.element(link).toBeInTheDocument(); + it('renders progressbar with correct aria-valuenow when resumeDoc is provided', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const bar = page.getByRole('progressbar'); + await expect.element(bar).toBeInTheDocument(); + await expect.element(bar).toHaveAttribute('aria-valuenow', '75'); + }); + + it('shows document title when resumeDoc is provided', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const title = page.getByRole('heading', { name: /Geburtsurkunde 1920/i }); + await expect.element(title).toBeInTheDocument(); + }); + + it('links to the document for the CTA', async () => { + render(DashboardResumeStrip, { resumeDoc: mockResume }); + const link = page.getByRole('link', { name: /Weitertranskribieren/i }); await expect.element(link).toHaveAttribute('href', '/documents/doc-123'); }); - - it('uses title fallback text when title is empty', async () => { - localStorage.setItem( - 'familienarchiv.lastVisited', - JSON.stringify({ id: 'doc-456', title: '' }) - ); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).toBeInTheDocument(); - const link = page.getByRole('link'); - await expect.element(link).toHaveAttribute('href', '/documents/doc-456'); - }); - - it('renders nothing when localStorage contains malformed JSON', async () => { - localStorage.setItem('familienarchiv.lastVisited', '{not valid json'); - render(DashboardResumeStrip, {}); - const strip = page.getByTestId('resume-strip'); - await expect.element(strip).not.toBeInTheDocument(); - }); }); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3aada446..0469053e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -28,7 +28,9 @@ onMount(() => { }); const isAuthPage = $derived( - ['/login', '/forgot-password', '/reset-password'].some((p) => page.url.pathname.startsWith(p)) + ['/login', '/register', '/forgot-password', '/reset-password'].some((p) => + page.url.pathname.startsWith(p) + ) ); const userInitials = $derived.by(() => { @@ -50,6 +52,30 @@ const userInitials = $derived.by(() => {
+ {#if data?.user} + + + Hochladen + + {/if}