feat(dashboard): add ReaderHeaderBar with greeting + stat columns (TDD, #483)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-08 16:54:02 +02:00
parent 2f48dfabd1
commit b5f9fcfdfd
2 changed files with 187 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
name: string;
documents: number | null;
persons: number | null;
stories: number | null;
hour?: number;
}
const { name, documents, persons, stories, hour }: Props = $props();
const timeLabel = $derived.by(() => {
const h = hour ?? new Date().getHours();
if (h < 12) return m.dashboard_greeting_time_morning();
if (h < 18) return m.dashboard_greeting_time_afternoon();
return m.dashboard_greeting_time_evening();
});
</script>
<header
class="flex flex-col items-start gap-4 rounded-sm border border-line bg-white px-4 py-3 sm:flex-row sm:items-center dark:border-white/8 dark:bg-surface"
>
<!-- Greeting -->
<div class="min-w-0 flex-1">
<span class="block text-[11px] font-bold tracking-[.8px] text-brand-navy uppercase">
{timeLabel}
</span>
<span class="block font-serif text-xl text-brand-navy">
{m.dashboard_welcome({ name })}
</span>
</div>
<!-- Vertical divider — desktop only -->
<div class="hidden w-px shrink-0 self-stretch bg-line sm:block" aria-hidden="true"></div>
<!-- Stats -->
<div
class="flex w-full items-center border-t border-line-soft pt-1.5 sm:w-auto sm:border-t-0 sm:pt-0"
>
<a
href="/documents"
class="flex min-h-[44px] flex-col items-center justify-center border-r border-line-soft px-3 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-brand-navy">{documents ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_documents()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_documents_short()}</span
>
</a>
<a
href="/persons"
class="flex min-h-[44px] flex-col items-center justify-center border-r border-line-soft px-3 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-brand-navy">{persons ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_persons()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_persons_short()}</span
>
</a>
<a
href="/geschichten"
class="flex min-h-[44px] flex-col items-center justify-center px-3 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
>
<span class="block text-2xl leading-none font-black text-brand-navy">{stories ?? '—'}</span>
<span
class="mt-0.5 hidden text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:block"
>{m.dashboard_reader_stats_stories()}</span
>
<span
class="mt-0.5 block text-[11px] font-bold tracking-[.8px] text-ink-3 uppercase sm:hidden"
>{m.dashboard_reader_stats_stories_short()}</span
>
</a>
</div>
</header>

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import ReaderHeaderBar from './ReaderHeaderBar.svelte';
afterEach(() => {
cleanup();
});
describe('ReaderHeaderBar', () => {
it('renders a link to /documents with document count', async () => {
render(ReaderHeaderBar, { name: 'Anna', 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 with person count', async () => {
render(ReaderHeaderBar, { name: 'Anna', 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 with story count', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /3/ });
await expect.element(link).toHaveAttribute('href', '/geschichten');
});
it('documents stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /42/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('persons stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /7/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('stories stat link has min-h-[44px] for touch target', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 42, persons: 7, stories: 3 });
const link = page.getByRole('link', { name: /3/ });
const cls = ((await link.element()) as HTMLElement).className;
expect(cls).toMatch(/min-h-\[44px\]/);
});
it('shows "—" when counts are null', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: null, persons: null, stories: null });
const wrapper = page.getByRole('banner');
const text = ((await wrapper.element()) as HTMLElement).textContent;
expect(text?.match(/—/g)?.length).toBeGreaterThanOrEqual(3);
});
it('time label uses text-brand-navy class for morning hour', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
const timeLabel = page.getByText(/Morgen/i);
await expect.element(timeLabel).toBeInTheDocument();
const cls = ((await timeLabel.element()) as HTMLElement).className;
expect(cls).toMatch(/text-brand-navy/);
});
it('shows afternoon label for hour 14', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 14 });
const timeLabel = page.getByText(/Mittag/i);
await expect.element(timeLabel).toBeInTheDocument();
});
it('shows evening label for hour 20', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 20 });
const timeLabel = page.getByText(/Abend/i);
await expect.element(timeLabel).toBeInTheDocument();
});
it('welcome line contains the user name', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1, hour: 8 });
const welcome = page.getByText(/Anna/);
await expect.element(welcome).toBeInTheDocument();
});
it('wrapper has dark mode classes', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
const wrapper = page.getByRole('banner');
const cls = ((await wrapper.element()) as HTMLElement).className;
expect(cls).toMatch(/dark:bg-surface/);
expect(cls).toMatch(/dark:border-white\/8/);
});
it('renders a vertical divider with bg-line class', async () => {
render(ReaderHeaderBar, { name: 'Anna', documents: 1, persons: 1, stories: 1 });
const wrapper = page.getByRole('banner');
const el = (await wrapper.element()) as HTMLElement;
const divider = el.querySelector('[aria-hidden="true"]');
expect(divider).not.toBeNull();
expect(divider!.className).toMatch(/bg-line/);
});
});