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:
81
frontend/src/routes/chronik/+page.server.ts
Normal file
81
frontend/src/routes/chronik/+page.server.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { createApiClient } from '$lib/api.server';
|
||||||
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||||
|
type NotificationDTO = components['schemas']['NotificationDTO'];
|
||||||
|
|
||||||
|
export type FilterValue = 'alle' | 'fuer-dich' | 'hochgeladen' | 'transkription' | 'kommentare';
|
||||||
|
|
||||||
|
const VALID_FILTERS: FilterValue[] = [
|
||||||
|
'alle',
|
||||||
|
'fuer-dich',
|
||||||
|
'hochgeladen',
|
||||||
|
'transkription',
|
||||||
|
'kommentare'
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseFilter(raw: string | null): FilterValue {
|
||||||
|
if (raw && (VALID_FILTERS as string[]).includes(raw)) return raw as FilterValue;
|
||||||
|
return 'alle';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load({ fetch, url }) {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const filter = parseFilter(url.searchParams.get('filter'));
|
||||||
|
const limit = Math.min(Number(url.searchParams.get('limit')) || 40, 40);
|
||||||
|
|
||||||
|
const [activityResult, unreadResult] = await Promise.allSettled([
|
||||||
|
api.GET('/api/dashboard/activity', { params: { query: { limit } } }),
|
||||||
|
api.GET('/api/notifications', {
|
||||||
|
params: { query: { read: false, page: 0, size: 20 } }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
let activityFeed: ActivityFeedItemDTO[] = [];
|
||||||
|
let unreadNotifications: NotificationDTO[] = [];
|
||||||
|
let loadError: string | null = null;
|
||||||
|
|
||||||
|
if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) {
|
||||||
|
activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? [];
|
||||||
|
} else if (activityResult.status === 'fulfilled') {
|
||||||
|
loadError = 'activity';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unreadResult.status === 'fulfilled' && unreadResult.value.response.ok) {
|
||||||
|
unreadNotifications = unreadResult.value.data?.content ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filter,
|
||||||
|
activityFeed,
|
||||||
|
unreadNotifications,
|
||||||
|
loadError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
dismiss: async ({ request, fetch }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = formData.get('id');
|
||||||
|
if (typeof id !== 'string' || id.length === 0) {
|
||||||
|
return fail(400, { error: 'missing id' });
|
||||||
|
}
|
||||||
|
const result = await api.PATCH('/api/notifications/{id}/read', {
|
||||||
|
params: { path: { id } }
|
||||||
|
});
|
||||||
|
if (!result.response.ok) {
|
||||||
|
return fail(result.response.status, { error: 'failed' });
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
'mark-all': async ({ fetch }) => {
|
||||||
|
const api = createApiClient(fetch);
|
||||||
|
const result = await api.POST('/api/notifications/read-all');
|
||||||
|
if (!result.response.ok) {
|
||||||
|
return fail(result.response.status, { error: 'failed' });
|
||||||
|
}
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
208
frontend/src/routes/chronik/+page.svelte
Normal file
208
frontend/src/routes/chronik/+page.svelte
Normal 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>
|
||||||
129
frontend/src/routes/chronik/page.server.spec.ts
Normal file
129
frontend/src/routes/chronik/page.server.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { actions, load } from './+page.server';
|
||||||
|
|
||||||
|
const mockApi = {
|
||||||
|
GET: vi.fn(),
|
||||||
|
POST: vi.fn(),
|
||||||
|
PATCH: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('$lib/api.server', () => ({
|
||||||
|
createApiClient: () => mockApi
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildUrl(search = ''): URL {
|
||||||
|
return new URL(`http://localhost/chronik${search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chronik/load', () => {
|
||||||
|
it('requests the activity feed with a 40-item limit', async () => {
|
||||||
|
mockApi.GET.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/dashboard/activity') {
|
||||||
|
return Promise.resolve({ response: { ok: true }, data: [] });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await load({ fetch, url: buildUrl() } as never);
|
||||||
|
|
||||||
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
|
||||||
|
params: { query: { limit: 40 } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests only unread notifications for Für-dich', async () => {
|
||||||
|
mockApi.GET.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/dashboard/activity') {
|
||||||
|
return Promise.resolve({ response: { ok: true }, data: [] });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await load({ fetch, url: buildUrl() } as never);
|
||||||
|
|
||||||
|
expect(mockApi.GET).toHaveBeenCalledWith('/api/notifications', {
|
||||||
|
params: { query: { read: false, page: 0, size: 20 } }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the activity feed and unread notifications on success', async () => {
|
||||||
|
const feed = [{ kind: 'FILE_UPLOADED', documentId: 'd1' }];
|
||||||
|
const unread = [{ id: 'n1', type: 'MENTION' }];
|
||||||
|
mockApi.GET.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/dashboard/activity') {
|
||||||
|
return Promise.resolve({ response: { ok: true }, data: feed });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ response: { ok: true }, data: { content: unread } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await load({ fetch, url: buildUrl() } as never);
|
||||||
|
|
||||||
|
expect(result.activityFeed).toEqual(feed);
|
||||||
|
expect(result.unreadNotifications).toEqual(unread);
|
||||||
|
expect(result.filter).toBe('alle');
|
||||||
|
expect(result.loadError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces "activity" loadError when the dashboard endpoint returns non-ok', async () => {
|
||||||
|
mockApi.GET.mockImplementation((path: string) => {
|
||||||
|
if (path === '/api/dashboard/activity') {
|
||||||
|
return Promise.resolve({ response: { ok: false, status: 500 }, error: {} });
|
||||||
|
}
|
||||||
|
return Promise.resolve({ response: { ok: true }, data: { content: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await load({ fetch, url: buildUrl() } as never);
|
||||||
|
|
||||||
|
expect(result.loadError).toBe('activity');
|
||||||
|
expect(result.activityFeed).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses the filter query param, falling back to "alle" for invalid values', async () => {
|
||||||
|
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||||
|
|
||||||
|
const validResult = await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
|
||||||
|
expect(validResult.filter).toBe('fuer-dich');
|
||||||
|
|
||||||
|
mockApi.GET.mockResolvedValue({ response: { ok: true }, data: [] });
|
||||||
|
const invalidResult = await load({ fetch, url: buildUrl('?filter=bogus') } as never);
|
||||||
|
expect(invalidResult.filter).toBe('alle');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chronik/actions', () => {
|
||||||
|
it('dismiss: PATCHes /api/notifications/{id}/read with the form id', async () => {
|
||||||
|
mockApi.PATCH.mockResolvedValue({ response: { ok: true } });
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('id', 'n-42');
|
||||||
|
|
||||||
|
const result = await actions.dismiss({
|
||||||
|
request: { formData: async () => formData },
|
||||||
|
fetch
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(mockApi.PATCH).toHaveBeenCalledWith('/api/notifications/{id}/read', {
|
||||||
|
params: { path: { id: 'n-42' } }
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismiss: fails with 400 when id is missing', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
const result = await actions.dismiss({
|
||||||
|
request: { formData: async () => formData },
|
||||||
|
fetch
|
||||||
|
} as never);
|
||||||
|
expect((result as { status: number }).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mark-all: POSTs /api/notifications/read-all', async () => {
|
||||||
|
mockApi.POST.mockResolvedValue({ response: { ok: true } });
|
||||||
|
const result = await actions['mark-all']({ fetch } as never);
|
||||||
|
expect(mockApi.POST).toHaveBeenCalledWith('/api/notifications/read-all');
|
||||||
|
expect(result).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user