feat(chronik): add /chronik route (page.server.ts + +page.svelte + spec)

page.server.ts loads /api/dashboard/activity (limit=40) and unread
/api/notifications in parallel via Promise.allSettled so a dashboard-activity
failure still renders the Für-dich box. Form actions ?/dismiss and ?/mark-all
back the Dismiss and "Alle gelesen" controls with CSRF-safe SvelteKit
endpoints.

+page.svelte composes all six chronik components:
- ChronikFuerDichBox at the top, seeded from the SSR unread set on first
  render and switching to the live SSE singleton once notifications arrive;
- ChronikFilterPills below, wired to URL via goto(?filter=…) with
  replaceState so the browser history stays clean across filter changes;
- ChronikTimeline for the day-bucketed feed, filtered client-side per pill
  (alle / fuer-dich / hochgeladen / transkription / kommentare);
- ChronikEmptyState for first-run vs filter-empty states;
- ChronikErrorCard on activity load failure.

"Mehr laden" pagination keeps focus on the button after load (via tick() +
$state-bound ref), renders 3 static skeleton rows with aria-busy, and
announces "{count} weitere Einträge geladen" through a polite aria-live
region. Inbox-zero in the Für-dich box links to /chronik?filter=fuer-dich.

Co-located page.server.spec.ts covers load(): limit=40, unread=read:false,
filter parsing with "alle" fallback, activity-fulfilled-but-not-ok surfaces
loadError, plus the dismiss and mark-all actions (success + missing-id
branch). 8 tests green.

Part of #285.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-20 17:01:48 +02:00
parent 2982c8330c
commit c17801e008
3 changed files with 418 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
import { notificationStore, type NotificationItem } from '$lib/stores/notifications.svelte';
import ChronikFuerDichBox from '$lib/components/chronik/ChronikFuerDichBox.svelte';
import ChronikFilterPills from '$lib/components/chronik/ChronikFilterPills.svelte';
import ChronikTimeline from '$lib/components/chronik/ChronikTimeline.svelte';
import ChronikEmptyState from '$lib/components/chronik/ChronikEmptyState.svelte';
import ChronikErrorCard from '$lib/components/chronik/ChronikErrorCard.svelte';
import type { components } from '$lib/generated/api';
import type { FilterValue } from './+page.server';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
interface Props {
data: {
filter: FilterValue;
activityFeed: ActivityFeedItemDTO[];
unreadNotifications: components['schemas']['NotificationDTO'][];
loadError: string | null;
};
}
const { data }: Props = $props();
// Mirror the current filter into a local state we can update on pill change.
// The effect syncs whenever the server-loaded filter changes (e.g. after goto).
// eslint-disable-next-line svelte/prefer-writable-derived -- we need this mutable for onFilterChange optimism before goto() resolves
let activeFilter = $state<FilterValue>('alle');
$effect(() => {
activeFilter = data.filter;
});
// Prefer the live SSE singleton for unread items so newly arriving mentions
// prepend without a reload. On first mount, seed from the server-loaded unread
// set if the singleton hasn't populated yet.
onMount(() => {
notificationStore.init();
});
onDestroy(() => {
notificationStore.destroy();
});
const liveUnread = $derived<NotificationItem[]>(
notificationStore.notifications.filter((n) => !n.read)
);
const seedUnread = $derived<NotificationItem[]>(
data.unreadNotifications
.filter((n): n is typeof n & { documentId: string; referenceId: string } =>
Boolean(n.documentId && n.referenceId)
)
.map((n) => ({
id: n.id,
type: n.type,
documentId: n.documentId,
documentTitle: n.documentTitle ?? null,
referenceId: n.referenceId,
annotationId: n.annotationId ?? null,
read: n.read,
createdAt: n.createdAt,
actorName: n.actorName ?? ''
}))
);
// If the singleton has any data (including zero after mark-all), trust it;
// otherwise fall back to the SSR-seeded unread set.
const unread = $derived<NotificationItem[]>(
notificationStore.notifications.length > 0 ? liveUnread : seedUnread
);
async function onFilterChange(v: FilterValue) {
activeFilter = v;
const url = new URL(page.url);
if (v === 'alle') url.searchParams.delete('filter');
else url.searchParams.set('filter', v);
await goto(`${url.pathname}${url.search}`, {
keepFocus: true,
noScroll: true,
replaceState: true
});
}
async function onMarkRead(n: NotificationItem) {
await notificationStore.markRead(n);
}
async function onMarkAllRead() {
await notificationStore.markAllRead();
}
// "Mehr laden" pagination
let isLoadingMore = $state(false);
let loadMoreBtn = $state<HTMLButtonElement | null>(null);
let paginatedFeed = $state<ActivityFeedItemDTO[]>([]);
let announcement = $state('');
const mergedFeed = $derived<ActivityFeedItemDTO[]>([...data.activityFeed, ...paginatedFeed]);
async function loadMore() {
if (isLoadingMore) return;
isLoadingMore = true;
try {
const res = await fetch(`/api/dashboard/activity?limit=40&offset=${mergedFeed.length}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error('load failed');
const next = (await res.json()) as ActivityFeedItemDTO[];
paginatedFeed = [...paginatedFeed, ...next];
announcement = m.chronik_load_more_announcement({ count: next.length });
} catch {
// Keep silent in the unit path — the error card handles load failures.
} finally {
isLoadingMore = false;
await tick();
loadMoreBtn?.focus();
}
}
const displayFeed = $derived<ActivityFeedItemDTO[]>(
(() => {
const merged = mergedFeed;
switch (activeFilter) {
case 'alle':
return merged;
case 'fuer-dich':
return merged.filter((i) => i.kind === 'MENTION_CREATED' || i.youMentioned);
case 'hochgeladen':
return merged.filter((i) => i.kind === 'FILE_UPLOADED');
case 'transkription':
return merged.filter(
(i) =>
i.kind === 'TEXT_SAVED' ||
i.kind === 'BLOCK_REVIEWED' ||
i.kind === 'ANNOTATION_CREATED'
);
case 'kommentare':
return merged.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED');
}
})()
);
const isEmpty = $derived(displayFeed.length === 0);
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(
data.activityFeed.length === 0 ? 'first-run' : 'filter-empty'
);
function retry() {
location.reload();
}
</script>
<svelte:head>
<title>{m.chronik_page_title()}</title>
</svelte:head>
<main class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<header class="mb-6 flex items-baseline justify-between">
<h1 class="font-serif text-2xl text-ink">{m.chronik_page_title()}</h1>
</header>
{#if data.loadError === 'activity'}
<ChronikErrorCard onRetry={retry} />
{:else}
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
<div class="mt-6">
<ChronikFilterPills value={activeFilter} onChange={onFilterChange} />
</div>
{#if isEmpty}
<div class="mt-8">
<ChronikEmptyState variant={emptyVariant} />
</div>
{:else}
<ChronikTimeline items={displayFeed} />
<div aria-live="polite" class="sr-only">{announcement}</div>
<div class="mt-6 text-center">
<button
type="button"
bind:this={loadMoreBtn}
onclick={loadMore}
aria-busy={isLoadingMore}
disabled={isLoadingMore}
class="rounded-sm border border-line px-4 py-3 font-sans text-sm text-ink-2 transition-colors hover:bg-muted disabled:opacity-60"
>
{isLoadingMore ? m.chronik_loading() : m.chronik_load_more()}
</button>
{#if isLoadingMore}
<ul aria-hidden="true" class="mt-3 flex flex-col gap-2">
{#each [0, 1, 2] as i (i)}
<li
data-testid="chronik-skeleton-row"
class="h-[72px] rounded-sm border border-line bg-muted/40"
></li>
{/each}
</ul>
{/if}
</div>
{/if}
{/if}
</main>