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
committed by marcel
parent e36c9382fc
commit cb3e5420fc
3 changed files with 418 additions and 0 deletions

View 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 });
});
});