From 2982c8330cd18327fdda93b3229d2dc5d1912b55 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 20 Apr 2026 16:54:22 +0200 Subject: [PATCH] feat(chronik): add six Chronik page components + co-located specs (40 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All under src/lib/components/chronik/: - ChronikRow.svelte — single orchestrator for four variants (comment / for-you / rollup / simple), discriminated via $derived. Outer wraps avatar + body + time; document title is a styled (no nested anchors). Rollup shows count badge + en-dash time range; for-you gets accent left border + @ marker hidden below sm:. - ChronikTimeline.svelte — buckets items by day using bucketByDay() and renders Heute/Gestern/Diese Woche/Älter section headers with trailing rule. - ChronikFuerDichBox.svelte — unread mentions card with inbox-zero variant, per-row Dismiss button (prevents bubbling, calls onMarkRead), aria-live count badge, and a .fade-in class gated by prefers-reduced-motion. - ChronikFilterPills.svelte — role=radiogroup with 5 pills, ArrowLeft/Right keyboard navigation wrapping across the group, single tabstop via dynamic tabindex. - ChronikEmptyState.svelte — three variants (first-run / filter-empty / inbox-zero) sharing a centered-column layout. - ChronikErrorCard.svelte — warning card with retry button, optional custom message override. Verbs map to chronik_singleton_* / chronik_rollup_* per AuditKind so no ICU pluralization is needed. Comment preview is a TODO placeholder (currently the document title) pending a backend preview DTO follow-up. All 40 unit tests green. No type-check or lint errors in these files. Part of #285. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chronik/ChronikEmptyState.svelte | 92 ++++++++++ .../chronik/ChronikEmptyState.svelte.spec.ts | 30 ++++ .../chronik/ChronikErrorCard.svelte | 46 +++++ .../chronik/ChronikErrorCard.svelte.spec.ts | 39 ++++ .../chronik/ChronikFilterPills.svelte | 68 +++++++ .../chronik/ChronikFilterPills.svelte.spec.ts | 85 +++++++++ .../chronik/ChronikFuerDichBox.svelte | 151 ++++++++++++++++ .../chronik/ChronikFuerDichBox.svelte.spec.ts | 116 ++++++++++++ .../lib/components/chronik/ChronikRow.svelte | 166 ++++++++++++++++++ .../chronik/ChronikRow.svelte.spec.ts | 121 +++++++++++++ .../components/chronik/ChronikTimeline.svelte | 66 +++++++ .../chronik/ChronikTimeline.svelte.spec.ts | 99 +++++++++++ 12 files changed, 1079 insertions(+) create mode 100644 frontend/src/lib/components/chronik/ChronikEmptyState.svelte create mode 100644 frontend/src/lib/components/chronik/ChronikEmptyState.svelte.spec.ts create mode 100644 frontend/src/lib/components/chronik/ChronikErrorCard.svelte create mode 100644 frontend/src/lib/components/chronik/ChronikErrorCard.svelte.spec.ts create mode 100644 frontend/src/lib/components/chronik/ChronikFilterPills.svelte create mode 100644 frontend/src/lib/components/chronik/ChronikFilterPills.svelte.spec.ts create mode 100644 frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte create mode 100644 frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte.spec.ts create mode 100644 frontend/src/lib/components/chronik/ChronikRow.svelte create mode 100644 frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts create mode 100644 frontend/src/lib/components/chronik/ChronikTimeline.svelte create mode 100644 frontend/src/lib/components/chronik/ChronikTimeline.svelte.spec.ts diff --git a/frontend/src/lib/components/chronik/ChronikEmptyState.svelte b/frontend/src/lib/components/chronik/ChronikEmptyState.svelte new file mode 100644 index 00000000..ecf2b865 --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikEmptyState.svelte @@ -0,0 +1,92 @@ + + +
+ {#if variant === 'first-run'} + + {:else if variant === 'filter-empty'} + + {:else} + + {/if} + +

+ {title} +

+ {#if body} +

+ {body} +

+ {/if} +
diff --git a/frontend/src/lib/components/chronik/ChronikEmptyState.svelte.spec.ts b/frontend/src/lib/components/chronik/ChronikEmptyState.svelte.spec.ts new file mode 100644 index 00000000..4e3446a4 --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikEmptyState.svelte.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ChronikEmptyState from './ChronikEmptyState.svelte'; + +afterEach(cleanup); + +describe('ChronikEmptyState', () => { + it('renders first-run variant title', async () => { + render(ChronikEmptyState, { variant: 'first-run' }); + await expect.element(page.getByText('Noch nichts geschehen')).toBeInTheDocument(); + }); + + it('renders filter-empty variant title', async () => { + render(ChronikEmptyState, { variant: 'filter-empty' }); + await expect.element(page.getByText('Nichts in dieser Ansicht')).toBeInTheDocument(); + }); + + it('renders inbox-zero variant title', async () => { + render(ChronikEmptyState, { variant: 'inbox-zero' }); + await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); + }); + + it('applies the expected data-variant attribute', async () => { + render(ChronikEmptyState, { variant: 'first-run' }); + const wrapper = document.querySelector('[data-testid="chronik-empty-state"]'); + expect(wrapper?.getAttribute('data-variant')).toBe('first-run'); + }); +}); diff --git a/frontend/src/lib/components/chronik/ChronikErrorCard.svelte b/frontend/src/lib/components/chronik/ChronikErrorCard.svelte new file mode 100644 index 00000000..98de6321 --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikErrorCard.svelte @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/lib/components/chronik/ChronikErrorCard.svelte.spec.ts b/frontend/src/lib/components/chronik/ChronikErrorCard.svelte.spec.ts new file mode 100644 index 00000000..2fecf383 --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikErrorCard.svelte.spec.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; + +import ChronikErrorCard from './ChronikErrorCard.svelte'; + +afterEach(cleanup); + +describe('ChronikErrorCard', () => { + it('renders the default error message', async () => { + render(ChronikErrorCard, { onRetry: vi.fn() }); + await expect + .element(page.getByText('Die Chronik konnte nicht geladen werden.')) + .toBeInTheDocument(); + }); + + it('renders the retry button with the expected label', async () => { + render(ChronikErrorCard, { onRetry: vi.fn() }); + await expect.element(page.getByText('Erneut versuchen')).toBeInTheDocument(); + }); + + it('renders a custom message when provided', async () => { + render(ChronikErrorCard, { onRetry: vi.fn(), message: 'Netzwerkfehler' }); + await expect.element(page.getByText('Netzwerkfehler')).toBeInTheDocument(); + }); + + it('calls onRetry when the retry button is clicked', async () => { + const onRetry = vi.fn(); + render(ChronikErrorCard, { onRetry }); + await userEvent.click(page.getByText('Erneut versuchen')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('has role="alert" on the wrapper', async () => { + render(ChronikErrorCard, { onRetry: vi.fn() }); + const alert = document.querySelector('[role="alert"]'); + expect(alert).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/chronik/ChronikFilterPills.svelte b/frontend/src/lib/components/chronik/ChronikFilterPills.svelte new file mode 100644 index 00000000..b692147c --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikFilterPills.svelte @@ -0,0 +1,68 @@ + + +
+ {#each pills as p (p.value)} + {@const active = p.value === value} + + {/each} +
diff --git a/frontend/src/lib/components/chronik/ChronikFilterPills.svelte.spec.ts b/frontend/src/lib/components/chronik/ChronikFilterPills.svelte.spec.ts new file mode 100644 index 00000000..54c450ff --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikFilterPills.svelte.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { userEvent } from 'vitest/browser'; + +import ChronikFilterPills from './ChronikFilterPills.svelte'; + +afterEach(cleanup); + +describe('ChronikFilterPills', () => { + it('renders all 5 filter pills', async () => { + render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() }); + const pills = document.querySelectorAll('[role="radio"]'); + expect(pills.length).toBe(5); + }); + + it('marks the active pill with aria-checked="true"', async () => { + render(ChronikFilterPills, { value: 'hochgeladen', onChange: vi.fn() }); + const pills = document.querySelectorAll('[role="radio"]'); + const checked = Array.from(pills).filter((p) => p.getAttribute('aria-checked') === 'true'); + expect(checked.length).toBe(1); + expect(checked[0].getAttribute('data-filter-value')).toBe('hochgeladen'); + }); + + it('calls onChange with the clicked pill value', async () => { + const onChange = vi.fn(); + render(ChronikFilterPills, { value: 'alle', onChange }); + const pill = document.querySelector( + '[data-filter-value="kommentare"]' + ) as HTMLButtonElement | null; + expect(pill).not.toBeNull(); + pill?.click(); + expect(onChange).toHaveBeenCalledWith('kommentare'); + }); + + it('applies active classes to the selected pill', async () => { + render(ChronikFilterPills, { value: 'fuer-dich', onChange: vi.fn() }); + const active = document.querySelector('[data-filter-value="fuer-dich"]'); + expect(active?.className).toContain('bg-primary'); + const inactive = document.querySelector('[data-filter-value="alle"]'); + expect(inactive?.className).toContain('bg-muted'); + }); + + it('ArrowRight moves focus to the next pill', async () => { + render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() }); + const first = document.querySelector('[data-filter-value="alle"]') as HTMLButtonElement | null; + const second = document.querySelector( + '[data-filter-value="fuer-dich"]' + ) as HTMLButtonElement | null; + expect(first).not.toBeNull(); + expect(second).not.toBeNull(); + first?.focus(); + await userEvent.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(second); + }); + + it('ArrowLeft moves focus to the previous pill', async () => { + render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() }); + const first = document.querySelector('[data-filter-value="alle"]') as HTMLButtonElement | null; + const second = document.querySelector( + '[data-filter-value="fuer-dich"]' + ) as HTMLButtonElement | null; + second?.focus(); + await userEvent.keyboard('{ArrowLeft}'); + expect(document.activeElement).toBe(first); + }); + + it('wraps focus from last to first with ArrowRight', async () => { + render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() }); + const last = document.querySelector( + '[data-filter-value="kommentare"]' + ) as HTMLButtonElement | null; + const first = document.querySelector('[data-filter-value="alle"]') as HTMLButtonElement | null; + last?.focus(); + await userEvent.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(first); + }); + + it('has role="radiogroup" on the container', async () => { + render(ChronikFilterPills, { value: 'alle', onChange: vi.fn() }); + const group = document.querySelector('[role="radiogroup"]'); + expect(group).not.toBeNull(); + // Paraglide provides "Aktivitäten filtern" as the filter label + expect(group?.getAttribute('aria-label')).toBe('Aktivitäten filtern'); + }); +}); diff --git a/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte b/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte new file mode 100644 index 00000000..39e7e754 --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte @@ -0,0 +1,151 @@ + + +
+ {#if unread.length === 0} + + {:else} +
+
+ + {m.chronik_for_you_caption()} + + + {m.chronik_for_you_count({ count: unread.length })} + +
+ +
+ + + {/if} +
+ + diff --git a/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte.spec.ts b/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte.spec.ts new file mode 100644 index 00000000..c8f709a8 --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikFuerDichBox.svelte.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; + +import ChronikFuerDichBox from './ChronikFuerDichBox.svelte'; +import type { NotificationItem } from '$lib/stores/notifications.svelte'; + +afterEach(cleanup); + +function notif(partial: Partial): NotificationItem { + return { + id: 'n1', + type: 'MENTION', + documentId: 'doc-1', + documentTitle: 'Ein Dokument', + referenceId: 'ref-1', + annotationId: null, + read: false, + createdAt: new Date(Date.now() - 5 * 60_000).toISOString(), + actorName: 'Anna', + ...partial + }; +} + +describe('ChronikFuerDichBox', () => { + it('renders inbox-zero state when there are no unread items', async () => { + render(ChronikFuerDichBox, { + unread: [], + onMarkRead: vi.fn(), + onMarkAllRead: vi.fn() + }); + const zero = document.querySelector('[data-testid="chronik-inbox-zero"]'); + expect(zero).not.toBeNull(); + await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); + }); + + it('links to the archived mentions in the inbox-zero state', async () => { + render(ChronikFuerDichBox, { + unread: [], + onMarkRead: vi.fn(), + onMarkAllRead: vi.fn() + }); + const link = document.querySelector('a[href="/chronik?filter=fuer-dich"]'); + expect(link).not.toBeNull(); + }); + + it('renders the count badge with correct total when unread exists', async () => { + render(ChronikFuerDichBox, { + unread: [notif({ id: 'a' }), notif({ id: 'b' })], + onMarkRead: vi.fn(), + onMarkAllRead: vi.fn() + }); + await expect.element(page.getByText('2 neu')).toBeInTheDocument(); + }); + + it('count badge has aria-live=polite when unread exists', async () => { + render(ChronikFuerDichBox, { + unread: [notif({ id: 'a' })], + onMarkRead: vi.fn(), + onMarkAllRead: vi.fn() + }); + // Wait for render + await expect.element(page.getByText('1 neu')).toBeInTheDocument(); + const badge = document.querySelector('[data-testid="chronik-fuerdich-count"]'); + expect(badge?.getAttribute('aria-live')).toBe('polite'); + expect(badge?.getAttribute('aria-atomic')).toBe('true'); + }); + + it('does not render the "Alle gelesen" button when there are no unread items', async () => { + render(ChronikFuerDichBox, { + unread: [], + onMarkRead: vi.fn(), + onMarkAllRead: vi.fn() + }); + await expect.element(page.getByText('Keine neuen Erwähnungen')).toBeInTheDocument(); + const all = document.querySelector('[data-testid="chronik-mark-all-read"]'); + expect(all).toBeNull(); + }); + + it('renders the "Alle gelesen" button when unread exists', async () => { + render(ChronikFuerDichBox, { + unread: [notif({ id: 'a' })], + onMarkRead: vi.fn(), + onMarkAllRead: vi.fn() + }); + await expect.element(page.getByText('Alle gelesen')).toBeInTheDocument(); + }); + + it('calls onMarkAllRead when the "Alle gelesen" button is clicked', async () => { + const onMarkAllRead = vi.fn(); + render(ChronikFuerDichBox, { + unread: [notif({ id: 'a' })], + onMarkRead: vi.fn(), + onMarkAllRead + }); + await userEvent.click(page.getByText('Alle gelesen')); + expect(onMarkAllRead).toHaveBeenCalledTimes(1); + }); + + it('calls onMarkRead (and not navigation) when a per-item Dismiss button is clicked', async () => { + const onMarkRead = vi.fn(); + const n = notif({ id: 'xyz' }); + render(ChronikFuerDichBox, { + unread: [n], + onMarkRead, + onMarkAllRead: vi.fn() + }); + const dismiss = document.querySelector( + '[data-testid="chronik-fuerdich-dismiss"]' + ) as HTMLButtonElement | null; + expect(dismiss).not.toBeNull(); + dismiss?.click(); + expect(onMarkRead).toHaveBeenCalledTimes(1); + expect(onMarkRead.mock.calls[0][0]).toEqual(n); + }); +}); diff --git a/frontend/src/lib/components/chronik/ChronikRow.svelte b/frontend/src/lib/components/chronik/ChronikRow.svelte new file mode 100644 index 00000000..a739f6db --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikRow.svelte @@ -0,0 +1,166 @@ + + + + + {#if item.actor} + + {:else} + + {/if} + + + {#if variant === 'for-you'} + + {/if} + + +
+

+ {verbParts.before}{docTitle}{verbParts.after} + {#if variant === 'rollup'} + + {item.count} + + {/if} +

+ + {#if variant === 'comment'} + +

+ „{docTitle}“ +

+ {/if} + +

{timeLabel}

+
+
diff --git a/frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts b/frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts new file mode 100644 index 00000000..41800bf8 --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikRow.svelte.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ChronikRow from './ChronikRow.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, + count: 1 +}; + +describe('ChronikRow', () => { + it('renders the document title', async () => { + render(ChronikRow, { item: baseItem }); + await expect.element(page.getByText('Brief 1920')).toBeInTheDocument(); + }); + + it('renders actor initials in avatar', async () => { + render(ChronikRow, { item: baseItem }); + await expect.element(page.getByText('MR')).toBeInTheDocument(); + }); + + it('renders "?" fallback avatar when actor is missing', async () => { + const item: ActivityFeedItemDTO = { ...baseItem, actor: undefined }; + render(ChronikRow, { item }); + const fallback = document.querySelector('[data-testid="chronik-avatar-fallback"]'); + expect(fallback).not.toBeNull(); + expect(fallback?.textContent?.trim()).toBe('?'); + }); + + it('wraps the row in a link to the document', async () => { + render(ChronikRow, { item: baseItem }); + const link = document.querySelector('a[href="/documents/doc-1"]'); + expect(link).not.toBeNull(); + }); + + // --- simple variant --- + it('renders simple variant when count === 1 and not a mention', async () => { + render(ChronikRow, { item: baseItem }); + // No rollup count badge + expect(document.querySelector('[data-testid="chronik-count-badge"]')).toBeNull(); + // No for-you marker + expect(document.querySelector('[data-testid="chronik-foryou-marker"]')).toBeNull(); + // No comment preview + expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull(); + }); + + // --- rollup variant --- + it('renders rollup variant with count badge when count > 1', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'TEXT_SAVED', + count: 3, + happenedAt: '2026-04-19T10:00:00Z', + happenedAtUntil: '2026-04-19T11:30:00Z' + }; + render(ChronikRow, { item }); + const badge = document.querySelector('[data-testid="chronik-count-badge"]'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toContain('3'); + }); + + it('renders a time range with an en-dash for rollup variant', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'FILE_UPLOADED', + count: 5, + happenedAt: '2026-04-19T10:00:00Z', + happenedAtUntil: '2026-04-19T11:30:00Z' + }; + render(ChronikRow, { item }); + // en-dash character U+2013 + const body = document.body.textContent ?? ''; + expect(body).toContain('\u2013'); + }); + + // --- for-you variant --- + it('renders for-you marker when youMentioned is true', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'MENTION_CREATED', + youMentioned: true + }; + render(ChronikRow, { item }); + const marker = document.querySelector('[data-testid="chronik-foryou-marker"]'); + expect(marker).not.toBeNull(); + }); + + it('applies accent border to for-you variant outer wrapper', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'MENTION_CREATED', + youMentioned: true + }; + render(ChronikRow, { item }); + const wrapper = document.querySelector('[data-variant="for-you"]'); + expect(wrapper).not.toBeNull(); + expect(wrapper?.className).toContain('border-accent'); + }); + + // --- comment variant --- + it('renders comment preview for COMMENT_ADDED kind', async () => { + const item: ActivityFeedItemDTO = { + ...baseItem, + kind: 'COMMENT_ADDED' + }; + render(ChronikRow, { item }); + const preview = document.querySelector('[data-testid="chronik-comment-preview"]'); + expect(preview).not.toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/chronik/ChronikTimeline.svelte b/frontend/src/lib/components/chronik/ChronikTimeline.svelte new file mode 100644 index 00000000..f083ba7b --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikTimeline.svelte @@ -0,0 +1,66 @@ + + +
+ {#each BUCKET_ORDER as bucket (bucket)} + {#if grouped[bucket].length > 0} +
+
+ + {bucketLabel(bucket)} + + +
+
    + {#each grouped[bucket] as it (it.kind + it.happenedAt + it.documentId)} +
  • + +
  • + {/each} +
+
+ {/if} + {/each} +
diff --git a/frontend/src/lib/components/chronik/ChronikTimeline.svelte.spec.ts b/frontend/src/lib/components/chronik/ChronikTimeline.svelte.spec.ts new file mode 100644 index 00000000..f65c1e70 --- /dev/null +++ b/frontend/src/lib/components/chronik/ChronikTimeline.svelte.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import ChronikTimeline from './ChronikTimeline.svelte'; +import type { components } from '$lib/generated/api'; + +type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO']; + +afterEach(cleanup); + +function item(partial: Partial): ActivityFeedItemDTO { + return { + kind: 'TEXT_SAVED', + actor: { initials: 'AB', color: '#123456', name: 'Anna Beta' }, + documentId: 'doc-x', + documentTitle: 'Some document', + happenedAt: new Date().toISOString(), + youMentioned: false, + count: 1, + ...partial + }; +} + +function atOffsetDays(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString(); +} + +describe('ChronikTimeline', () => { + it('renders nothing / no bucket headers when items is empty', async () => { + render(ChronikTimeline, { items: [] }); + expect(document.querySelector('[data-testid="chronik-bucket-today"]')).toBeNull(); + expect(document.querySelector('[data-testid="chronik-bucket-yesterday"]')).toBeNull(); + expect(document.querySelector('[data-testid="chronik-bucket-thisWeek"]')).toBeNull(); + expect(document.querySelector('[data-testid="chronik-bucket-older"]')).toBeNull(); + }); + + it('places today items in the today bucket with a "Heute" header', async () => { + render(ChronikTimeline, { + items: [ + item({ + documentId: 'doc-today', + documentTitle: 'Frisches Dokument', + happenedAt: new Date().toISOString() + }) + ] + }); + const today = document.querySelector('[data-testid="chronik-bucket-today"]'); + expect(today).not.toBeNull(); + await expect.element(page.getByText('Heute', { exact: true })).toBeInTheDocument(); + // The row for the today item should be inside the today bucket. + expect(today?.textContent).toContain('Frisches Dokument'); + }); + + it('does not render an empty bucket header when no items fall into it', async () => { + render(ChronikTimeline, { + items: [item({ happenedAt: new Date().toISOString() })] + }); + // Only today bucket should exist. + expect(document.querySelector('[data-testid="chronik-bucket-today"]')).not.toBeNull(); + expect(document.querySelector('[data-testid="chronik-bucket-older"]')).toBeNull(); + }); + + it('places older items in the older bucket', async () => { + render(ChronikTimeline, { + items: [ + item({ + documentId: 'doc-old', + documentTitle: 'Alt Doc', + happenedAt: atOffsetDays(30) + }) + ] + }); + const older = document.querySelector('[data-testid="chronik-bucket-older"]'); + expect(older).not.toBeNull(); + expect(older?.textContent).toContain('Alt Doc'); + }); + + it('groups multiple items into their respective buckets', async () => { + render(ChronikTimeline, { + items: [ + item({ + documentId: 'd1', + documentTitle: 'Heute Item', + happenedAt: new Date().toISOString() + }), + item({ documentId: 'd2', documentTitle: 'Alt Item', happenedAt: atOffsetDays(30) }) + ] + }); + const today = document.querySelector('[data-testid="chronik-bucket-today"]'); + const older = document.querySelector('[data-testid="chronik-bucket-older"]'); + expect(today?.textContent).toContain('Heute Item'); + expect(today?.textContent).not.toContain('Alt Item'); + expect(older?.textContent).toContain('Alt Item'); + expect(older?.textContent).not.toContain('Heute Item'); + }); +});