refactor: move activity domain components to lib/activity/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,112 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
interface Props {
|
||||
feed: ActivityFeedItemDTO[];
|
||||
}
|
||||
|
||||
const { feed }: Props = $props();
|
||||
|
||||
const verbMap: Record<string, string> = {
|
||||
TEXT_SAVED: m.audit_action_text_saved(),
|
||||
FILE_UPLOADED: m.audit_action_file_uploaded(),
|
||||
ANNOTATION_CREATED: m.audit_action_annotation_created(),
|
||||
BLOCK_REVIEWED: m.audit_action_annotation_created(),
|
||||
COMMENT_ADDED: m.audit_action_comment_added(),
|
||||
MENTION_CREATED: m.audit_action_mention_created()
|
||||
};
|
||||
|
||||
function verb(kind: string): string {
|
||||
return verbMap[kind] ?? kind;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
}).format(new Date(iso));
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }).format(
|
||||
new Date(iso)
|
||||
);
|
||||
}
|
||||
|
||||
// Rollup rows get "14. Apr. · 14:02–14:32"; singletons stay "14. Apr. 2026".
|
||||
function timestamp(item: ActivityFeedItemDTO): string {
|
||||
if (item.happenedAtUntil && item.count > 1) {
|
||||
const short = new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
}).format(new Date(item.happenedAt));
|
||||
return `${short} \u00b7 ${formatTime(item.happenedAt)}\u2013${formatTime(item.happenedAtUntil)}`;
|
||||
}
|
||||
return formatDate(item.happenedAt);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rounded-sm border border-line bg-surface p-5">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h2 class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.feed_caption()}
|
||||
</h2>
|
||||
<a
|
||||
href="/aktivitaeten"
|
||||
aria-label={m.feed_show_all()}
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink">{m.feed_show_all()}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if feed.length > 0}
|
||||
<ul class="flex flex-col gap-3">
|
||||
{#each feed as item (item.happenedAt + item.documentId + item.kind)}
|
||||
<li class="flex items-start gap-3">
|
||||
{#if item.actor}
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-sans text-sm font-bold text-white"
|
||||
style="background:{item.actor.color}">{item.actor.initials}</span
|
||||
>
|
||||
{:else}
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-line font-sans text-sm text-ink-3"
|
||||
>?</span
|
||||
>
|
||||
{/if}
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-sans text-sm leading-snug text-ink">
|
||||
{#if item.actor}
|
||||
<strong>{item.actor.name ?? item.actor.initials}</strong>
|
||||
{/if}
|
||||
{verb(item.kind)}
|
||||
<a href="/documents/{item.documentId}" class="underline hover:text-ink">
|
||||
{item.documentTitle}
|
||||
</a>
|
||||
{#if item.count > 1}
|
||||
<span
|
||||
data-testid="feed-rollup-count"
|
||||
class="ml-1.5 inline-block rounded-sm bg-primary px-2 py-0.5 font-sans text-[10px] font-bold text-primary-fg"
|
||||
>
|
||||
{item.count}
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.youMentioned}
|
||||
<span
|
||||
class="ml-1.5 inline-block rounded-full border border-accent px-2 py-px font-sans text-[10px] font-bold text-accent"
|
||||
>
|
||||
{m.feed_for_you()}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<p class="mt-0.5 font-sans text-xs text-ink-3">{timestamp(item)}</p>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1,69 +0,0 @@
|
||||
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,
|
||||
count: 1
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('renders count badge and en-dash time range for rollup rows (count > 1)', async () => {
|
||||
const rollup: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
count: 20,
|
||||
happenedAtUntil: '2026-04-19T10:32:00Z'
|
||||
};
|
||||
render(DashboardActivityFeed, { feed: [rollup] });
|
||||
const badge = page.getByTestId('feed-rollup-count');
|
||||
await expect.element(badge).toHaveTextContent('20');
|
||||
// "–" is U+2013 en-dash
|
||||
const stamp = page.getByText(/\u2013/);
|
||||
await expect.element(stamp).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render count badge for singleton rows (count === 1)', async () => {
|
||||
render(DashboardActivityFeed, { feed: [baseItem] });
|
||||
const badge = page.getByTestId('feed-rollup-count');
|
||||
await expect.element(badge).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links the "show all" footer to /aktivitaeten, not /documents', async () => {
|
||||
render(DashboardActivityFeed, { feed: [] });
|
||||
const link = page.getByRole('link', { name: /alle anzeigen/i });
|
||||
await expect.element(link).toHaveAttribute('href', '/aktivitaeten');
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
export type EmptyVariant = 'first-run' | 'filter-empty' | 'inbox-zero';
|
||||
|
||||
interface Props {
|
||||
variant: EmptyVariant;
|
||||
}
|
||||
|
||||
const { variant }: Props = $props();
|
||||
|
||||
const title: string = $derived(
|
||||
variant === 'first-run'
|
||||
? m.chronik_empty_first_run_title()
|
||||
: variant === 'filter-empty'
|
||||
? m.chronik_empty_filter_title()
|
||||
: m.chronik_inbox_zero_title()
|
||||
);
|
||||
|
||||
const body: string = $derived(
|
||||
variant === 'first-run'
|
||||
? m.chronik_empty_first_run_body()
|
||||
: variant === 'filter-empty'
|
||||
? m.chronik_empty_filter_body()
|
||||
: ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-testid="chronik-empty-state"
|
||||
data-variant={variant}
|
||||
class="flex flex-col items-center gap-3 py-10 text-center"
|
||||
>
|
||||
{#if variant === 'first-run'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-ink-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if variant === 'filter-empty'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-ink-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 4h18M6 8h12M9 12h6M10 16h4M11 20h2"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-accent"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15L15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<p class="font-sans text-base font-bold text-ink">
|
||||
{title}
|
||||
</p>
|
||||
{#if body}
|
||||
<p class="max-w-md font-sans text-sm text-ink-3">
|
||||
{body}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,30 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
onRetry: () => void;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const { onRetry, message }: Props = $props();
|
||||
|
||||
const displayMessage: string = $derived(message ?? m.chronik_error_title());
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="alert"
|
||||
class="flex items-start gap-3 rounded-sm border border-warning/40 bg-warning/10 p-4"
|
||||
>
|
||||
<span class="mt-0.5 text-warning-fg" aria-hidden="true">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-sans text-sm text-warning-fg">
|
||||
{displayMessage}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRetry}
|
||||
class="mt-2 inline-flex items-center rounded-sm bg-warning-fg px-3 py-1 font-sans text-xs font-medium text-surface transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
{m.chronik_error_retry()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,39 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
export type FilterValue = 'alle' | 'fuer-dich' | 'hochgeladen' | 'transkription' | 'kommentare';
|
||||
|
||||
interface Props {
|
||||
value: FilterValue;
|
||||
onChange: (v: FilterValue) => void;
|
||||
}
|
||||
|
||||
const { value, onChange }: Props = $props();
|
||||
|
||||
type Pill = { value: FilterValue; label: () => string };
|
||||
|
||||
const pills: Pill[] = [
|
||||
{ value: 'alle', label: () => m.chronik_filter_all() },
|
||||
{ value: 'fuer-dich', label: () => m.chronik_filter_for_you() },
|
||||
{ value: 'hochgeladen', label: () => m.chronik_filter_uploaded() },
|
||||
{ value: 'transkription', label: () => m.chronik_filter_transcription() },
|
||||
{ value: 'kommentare', label: () => m.chronik_filter_comments() }
|
||||
];
|
||||
|
||||
let container: HTMLDivElement | undefined = $state();
|
||||
|
||||
function moveFocus(direction: 1 | -1, fromValue: FilterValue): void {
|
||||
if (!container) return;
|
||||
const idx = pills.findIndex((p) => p.value === fromValue);
|
||||
if (idx === -1) return;
|
||||
const nextIdx = (idx + direction + pills.length) % pills.length;
|
||||
const nextValue = pills[nextIdx].value;
|
||||
const target = container.querySelector<HTMLButtonElement>(`[data-filter-value="${nextValue}"]`);
|
||||
target?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, v: FilterValue): void {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
moveFocus(1, v);
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
moveFocus(-1, v);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={container}
|
||||
role="radiogroup"
|
||||
aria-label={m.chronik_filter_label()}
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
{#each pills as p (p.value)}
|
||||
{@const active = p.value === value}
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabindex={active ? 0 : -1}
|
||||
data-filter-value={p.value}
|
||||
onclick={() => onChange(p.value)}
|
||||
onkeydown={(e) => handleKeydown(e, p.value)}
|
||||
class="inline-flex min-h-[44px] items-center rounded-sm px-4 py-2 font-sans text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
|
||||
{active ? 'bg-primary text-primary-fg' : 'bg-muted text-ink hover:bg-muted/80'}"
|
||||
>
|
||||
{p.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,85 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/utils/time';
|
||||
import type { NotificationItem } from '$lib/stores/notifications.svelte';
|
||||
import { buildCommentHref } from '$lib/utils/commentDeepLink';
|
||||
|
||||
interface Props {
|
||||
unread: NotificationItem[];
|
||||
onMarkRead: (n: NotificationItem) => void;
|
||||
onMarkAllRead: () => void;
|
||||
}
|
||||
|
||||
const { unread, onMarkRead, onMarkAllRead }: Props = $props();
|
||||
|
||||
function verb(type: NotificationItem['type'], actor: string): string {
|
||||
return type === 'REPLY'
|
||||
? m.notification_type_reply({ actor })
|
||||
: m.notification_type_mention({ actor });
|
||||
}
|
||||
|
||||
function href(n: NotificationItem): string {
|
||||
return buildCommentHref(n.documentId, n.referenceId, n.annotationId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rounded-sm border border-line bg-surface p-5">
|
||||
{#if unread.length === 0}
|
||||
<div data-testid="chronik-inbox-zero" class="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-accent"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15L15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.75c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="font-sans text-sm font-bold text-ink">
|
||||
{m.chronik_inbox_zero_title()}
|
||||
</p>
|
||||
<a
|
||||
href="/aktivitaeten?filter=fuer-dich"
|
||||
class="font-sans text-xs text-ink-3 underline decoration-accent underline-offset-2 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.chronik_inbox_zero_link()}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.chronik_for_you_caption()}
|
||||
</span>
|
||||
<span
|
||||
data-testid="chronik-fuerdich-count"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
class="inline-block rounded-sm bg-primary px-2 py-0.5 font-sans text-xs text-primary-fg"
|
||||
>
|
||||
{m.chronik_for_you_count({ count: unread.length })}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chronik-mark-all-read"
|
||||
onclick={onMarkAllRead}
|
||||
class="font-sans text-xs font-medium text-ink-3 transition-colors hover:text-ink"
|
||||
>
|
||||
{m.chronik_mark_all_read()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul role="list" class="flex flex-col gap-2">
|
||||
{#each unread as n (n.id)}
|
||||
<li
|
||||
class="fade-in group flex items-start gap-3 rounded-sm p-2 transition-colors hover:bg-canvas"
|
||||
>
|
||||
<a
|
||||
href={href(n)}
|
||||
class="flex min-w-0 flex-1 items-start gap-3 rounded-sm focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent"
|
||||
>
|
||||
{n.type === 'MENTION' ? '@' : '\u21A9'}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-sans text-sm leading-snug text-ink">
|
||||
{verb(n.type, n.actorName)}
|
||||
</p>
|
||||
<p class="mt-0.5 font-sans text-xs text-ink-3">
|
||||
{relativeTime(n.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chronik-fuerdich-dismiss"
|
||||
aria-label={m.chronik_mark_read_aria()}
|
||||
onclick={() => onMarkRead(n)}
|
||||
class="mt-0.5 shrink-0 rounded-sm p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.fade-in {
|
||||
animation: chronik-fade-in 160ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes chronik-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fade-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,148 +0,0 @@
|
||||
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>): 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="/aktivitaeten?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);
|
||||
});
|
||||
|
||||
it('mention row href includes both commentId and annotationId when annotationId is present', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [
|
||||
notif({
|
||||
id: 'n-link',
|
||||
documentId: 'doc-42',
|
||||
referenceId: 'comment-7',
|
||||
annotationId: 'annot-9'
|
||||
})
|
||||
],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
const link = document.querySelector(
|
||||
'a[href="/documents/doc-42?commentId=comment-7&annotationId=annot-9"]'
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('Dismiss button is a sibling of the document link, never nested inside <a>', async () => {
|
||||
render(ChronikFuerDichBox, {
|
||||
unread: [notif({ id: 'x' })],
|
||||
onMarkRead: vi.fn(),
|
||||
onMarkAllRead: vi.fn()
|
||||
});
|
||||
const dismiss = document.querySelector('[data-testid="chronik-fuerdich-dismiss"]');
|
||||
expect(dismiss).not.toBeNull();
|
||||
// HTML spec forbids interactive content descendants of <a>.
|
||||
// Prevents the senior-audience tap-drag bug flagged by Leonie.
|
||||
expect(dismiss?.closest('a')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTime } from '$lib/utils/time';
|
||||
import { buildCommentHref } from '$lib/utils/commentDeepLink';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
type Variant = 'comment' | 'for-you' | 'rollup' | 'simple';
|
||||
|
||||
interface Props {
|
||||
item: ActivityFeedItemDTO;
|
||||
}
|
||||
|
||||
const { item }: Props = $props();
|
||||
|
||||
const variant: Variant = $derived(
|
||||
item.kind === 'COMMENT_ADDED'
|
||||
? 'comment'
|
||||
: item.youMentioned
|
||||
? 'for-you'
|
||||
: item.count > 1
|
||||
? 'rollup'
|
||||
: 'simple'
|
||||
);
|
||||
|
||||
function verbSingleton(kind: string, actor: string, doc: string): string {
|
||||
switch (kind) {
|
||||
case 'TEXT_SAVED':
|
||||
return m.chronik_singleton_text_saved({ actor, doc });
|
||||
case 'FILE_UPLOADED':
|
||||
return m.chronik_singleton_uploaded({ actor, doc });
|
||||
case 'BLOCK_REVIEWED':
|
||||
return m.chronik_singleton_reviewed({ actor, doc });
|
||||
case 'ANNOTATION_CREATED':
|
||||
return m.chronik_singleton_annotated({ actor, doc });
|
||||
case 'COMMENT_ADDED':
|
||||
return m.chronik_comment_added({ actor, doc });
|
||||
case 'MENTION_CREATED':
|
||||
return m.chronik_mention_created({ actor, doc });
|
||||
default:
|
||||
return `${actor} · ${doc}`;
|
||||
}
|
||||
}
|
||||
|
||||
function verbRollup(kind: string, actor: string, doc: string, count: number): string {
|
||||
switch (kind) {
|
||||
case 'TEXT_SAVED':
|
||||
return m.chronik_rollup_text_saved({ actor, doc, count });
|
||||
case 'FILE_UPLOADED':
|
||||
return m.chronik_rollup_uploaded({ actor, count });
|
||||
case 'BLOCK_REVIEWED':
|
||||
return m.chronik_rollup_reviewed({ actor, doc, count });
|
||||
case 'ANNOTATION_CREATED':
|
||||
return m.chronik_rollup_annotated({ actor, doc, count });
|
||||
default:
|
||||
return verbSingleton(kind, actor, doc);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeHHMM(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
const actorName: string = $derived(item.actor?.name ?? item.actor?.initials ?? '?');
|
||||
const docTitle: string = $derived(item.documentTitle);
|
||||
|
||||
// We split the translated verb around the document title so the title can be
|
||||
// rendered as a styled <span> inside the <a> without nesting anchors. Using a
|
||||
// non-printable sentinel (U+0001) as the {doc} interpolation value lets us
|
||||
// split the compiled message regardless of what the actual title contains —
|
||||
// empty strings, short substrings that also appear in the verb, and any
|
||||
// translator sentence order all work without special cases.
|
||||
const SENTINEL = '\u0001';
|
||||
|
||||
const verbText: string = $derived(
|
||||
variant === 'rollup'
|
||||
? verbRollup(item.kind, actorName, SENTINEL, item.count)
|
||||
: verbSingleton(item.kind, actorName, SENTINEL)
|
||||
);
|
||||
|
||||
const timeLabel: string = $derived(
|
||||
variant === 'rollup' && item.happenedAtUntil
|
||||
? `${formatTimeHHMM(item.happenedAt)}\u2013${formatTimeHHMM(item.happenedAtUntil)}`
|
||||
: relativeTime(item.happenedAt)
|
||||
);
|
||||
|
||||
const verbParts: { before: string; after: string } = $derived.by(() => {
|
||||
const idx = verbText.indexOf(SENTINEL);
|
||||
if (idx === -1) return { before: verbText, after: '' };
|
||||
return {
|
||||
before: verbText.slice(0, idx),
|
||||
after: verbText.slice(idx + SENTINEL.length)
|
||||
};
|
||||
});
|
||||
|
||||
const rowHref: string = $derived(
|
||||
item.commentId
|
||||
? buildCommentHref(item.documentId, item.commentId, item.annotationId ?? null)
|
||||
: `/documents/${item.documentId}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={rowHref}
|
||||
data-variant={variant}
|
||||
class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
|
||||
{variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
{#if item.actor}
|
||||
<span
|
||||
class="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full font-sans text-sm font-bold text-white"
|
||||
style="background:{item.actor.color}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{item.actor.initials}
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
data-testid="chronik-avatar-fallback"
|
||||
class="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-line font-sans text-sm text-ink-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- For-you marker (hidden on mobile) -->
|
||||
{#if variant === 'for-you'}
|
||||
<span
|
||||
data-testid="chronik-foryou-marker"
|
||||
aria-hidden="true"
|
||||
class="mt-1 hidden h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent-bg font-sans text-xs font-bold text-accent sm:inline-flex"
|
||||
>
|
||||
@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Body -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-sans text-sm leading-snug text-ink">
|
||||
{verbParts.before}<span
|
||||
data-testid="chronik-doc-title"
|
||||
class="underline decoration-accent underline-offset-2">{docTitle}</span
|
||||
>{verbParts.after}
|
||||
{#if variant === 'rollup'}
|
||||
<span
|
||||
data-testid="chronik-count-badge"
|
||||
class="ml-1 inline-block rounded-sm bg-primary px-2 py-0.5 font-sans text-xs text-primary-fg"
|
||||
>
|
||||
{item.count}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if variant === 'comment'}
|
||||
<!--
|
||||
TODO: the backend does not yet expose a comment body preview on
|
||||
ActivityFeedItemDTO. Render an ellipsis placeholder until it does —
|
||||
duplicating the document title here looks like the comment is
|
||||
quoting itself (Leonie, PR #288 review).
|
||||
SECURITY: once item.commentPreview lands, render via {text}, never
|
||||
{@html}. The backend must truncate and strip tags server-side (Nora,
|
||||
issue #285 comment #3552).
|
||||
-->
|
||||
<p
|
||||
data-testid="chronik-comment-preview"
|
||||
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
|
||||
>
|
||||
„…“
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<p class="mt-0.5 font-sans text-xs text-ink-3">{timeLabel}</p>
|
||||
</div>
|
||||
</a>
|
||||
@@ -1,207 +0,0 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it('comment preview does NOT duplicate the document title verbatim', async () => {
|
||||
// Leonie: user sees the title twice otherwise — looks like the comment is quoting itself.
|
||||
// Until the backend exposes item.commentPreview, the placeholder must be distinct.
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
documentTitle: 'Brief vom 12. Juli 1920'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
|
||||
expect(preview).not.toBeNull();
|
||||
expect(preview?.textContent).not.toContain('Brief vom 12. Juli 1920');
|
||||
});
|
||||
|
||||
// --- deep-link href for comment events ---
|
||||
it('links to /documents/:id?commentId=…&annotationId=… for COMMENT_ADDED', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentId: 'comment-7',
|
||||
annotationId: 'annot-9'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector(
|
||||
'a[href="/documents/doc-1?commentId=comment-7&annotationId=annot-9"]'
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('links to /documents/:id?commentId=…&annotationId=… for MENTION_CREATED', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'MENTION_CREATED',
|
||||
youMentioned: true,
|
||||
commentId: 'comment-8',
|
||||
annotationId: 'annot-11'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector(
|
||||
'a[href="/documents/doc-1?commentId=comment-8&annotationId=annot-11"]'
|
||||
);
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to bare document href when commentId is absent on a comment row', async () => {
|
||||
// Back-compat for old/missing backend payloads. Still navigates sensibly.
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED'
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector('a[href="/documents/doc-1"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('links to commentId-only URL when commentId is set but annotationId is absent', async () => {
|
||||
const item: ActivityFeedItemDTO = {
|
||||
...baseItem,
|
||||
kind: 'COMMENT_ADDED',
|
||||
commentId: 'comment-7'
|
||||
// annotationId absent — comment on a non-annotation block
|
||||
};
|
||||
render(ChronikRow, { item });
|
||||
const link = document.querySelector('a[href="/documents/doc-1?commentId=comment-7"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
// --- robustness: title rendering for edge cases ---
|
||||
it('still renders the row link when documentTitle is an empty string', async () => {
|
||||
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span
|
||||
// collapsed and before/after both emptied out. Swap to a sentinel-based
|
||||
// approach so this case renders like every other row.
|
||||
const empty: ActivityFeedItemDTO = { ...baseItem, documentTitle: '' };
|
||||
render(ChronikRow, { item: empty });
|
||||
const link = document.querySelector('a[href="/documents/doc-1"]');
|
||||
expect(link).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders a short document title that could substring-match the verb', async () => {
|
||||
const short: ActivityFeedItemDTO = { ...baseItem, documentTitle: 'Brief' };
|
||||
render(ChronikRow, { item: short });
|
||||
const titleEls = document.querySelectorAll('[data-testid="chronik-doc-title"]');
|
||||
expect(titleEls.length).toBe(1);
|
||||
expect(titleEls[0].textContent).toBe('Brief');
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { bucketByDay, type DayBucket } from '$lib/utils/date-buckets';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import ChronikRow from './ChronikRow.svelte';
|
||||
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
|
||||
interface Props {
|
||||
items: ActivityFeedItemDTO[];
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
const { items, locale }: Props = $props();
|
||||
|
||||
const BUCKET_ORDER: DayBucket[] = ['today', 'yesterday', 'thisWeek', 'older'];
|
||||
|
||||
function bucketLabel(bucket: DayBucket): string {
|
||||
switch (bucket) {
|
||||
case 'today':
|
||||
return m.chronik_day_today();
|
||||
case 'yesterday':
|
||||
return m.chronik_day_yesterday();
|
||||
case 'thisWeek':
|
||||
return m.chronik_day_this_week();
|
||||
case 'older':
|
||||
return m.chronik_day_older();
|
||||
}
|
||||
}
|
||||
|
||||
const grouped: Record<DayBucket, ActivityFeedItemDTO[]> = $derived.by(() => {
|
||||
const result: Record<DayBucket, ActivityFeedItemDTO[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
older: []
|
||||
};
|
||||
for (const it of items) {
|
||||
const b = bucketByDay(new Date(it.happenedAt), new Date(), locale);
|
||||
result[b].push(it);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mt-6 flex flex-col">
|
||||
{#each BUCKET_ORDER as bucket (bucket)}
|
||||
{#if grouped[bucket].length > 0}
|
||||
<section
|
||||
data-testid="chronik-bucket-{bucket}"
|
||||
class="mb-4 overflow-hidden border border-line bg-surface shadow-sm"
|
||||
>
|
||||
<div class="border-b border-line bg-muted px-5 py-2">
|
||||
<span class="font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{bucketLabel(bucket)}
|
||||
</span>
|
||||
</div>
|
||||
<ul role="list" class="divide-y divide-line">
|
||||
{#each grouped[bucket] as it (it.kind + it.happenedAt + it.documentId)}
|
||||
<li>
|
||||
<ChronikRow item={it} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -1,99 +0,0 @@
|
||||
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>): 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user