feat(chronik): rename route and heading to Aktivitäten
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m48s
CI / OCR Service Tests (push) Successful in 31s
CI / Backend Unit Tests (push) Failing after 2m43s

/chronik → /aktivitaeten; heading updated in all three locales.
Component folder (lib/components/chronik/) stays unchanged — internal
implementation detail, not user-facing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 09:28:09 +02:00
parent 4f671824dd
commit fd93f1a4da
15 changed files with 23 additions and 20 deletions

View File

@@ -0,0 +1,67 @@
import { createApiClient } from '$lib/api.server';
import type { components, operations } from '$lib/generated/api';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
type NotificationDTO = components['schemas']['NotificationDTO'];
type AuditKind = NonNullable<operations['getActivity']['parameters']['query']>['kinds'] extends
| (infer K)[]
| undefined
? K
: never;
export type FilterValue = 'alle' | 'fuer-dich' | 'hochgeladen' | 'transkription' | 'kommentare';
const VALID_FILTERS: FilterValue[] = [
'alle',
'fuer-dich',
'hochgeladen',
'transkription',
'kommentare'
];
// fuer-dich stays client-side: youMentioned || youParticipated cannot be expressed as a kinds filter
const KINDS_FOR_FILTER: Partial<Record<FilterValue, AuditKind[]>> = {
hochgeladen: ['FILE_UPLOADED'],
transkription: ['TEXT_SAVED', 'BLOCK_REVIEWED', 'ANNOTATION_CREATED'],
kommentare: ['COMMENT_ADDED', 'MENTION_CREATED']
};
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 kinds = KINDS_FOR_FILTER[filter];
const [activityResult, unreadResult] = await Promise.allSettled([
api.GET('/api/dashboard/activity', { params: { query: { limit, ...(kinds && { kinds }) } } }),
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
};
}

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page, navigating } 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';
import { applyClientFilter } from './clientFilter';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
interface Props {
data: {
filter: FilterValue;
activityFeed: ActivityFeedItemDTO[];
unreadNotifications: components['schemas']['NotificationDTO'][];
loadError: string | null;
};
}
const { data }: Props = $props();
// 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) {
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();
}
const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter));
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={data.filter} onChange={onFilterChange} />
</div>
<div aria-live="polite" aria-atomic="false" aria-busy={!!navigating.type}>
{#if isEmpty}
<div class="mt-8">
<ChronikEmptyState variant={emptyVariant} />
</div>
{:else}
<ChronikTimeline items={displayFeed} />
{/if}
</div>
{/if}
</main>

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { applyClientFilter } from './clientFilter';
import type { components } from '$lib/generated/api';
type Item = components['schemas']['ActivityFeedItemDTO'];
function makeItem(overrides: Partial<Item> = {}): Item {
return {
kind: 'FILE_UPLOADED',
documentId: 'd1',
documentTitle: 'Brief A',
happenedAt: '2026-04-20T10:00:00Z',
youMentioned: false,
youParticipated: false,
count: 1,
actor: null,
happenedAtUntil: null,
...overrides
};
}
describe('applyClientFilter', () => {
describe('non-fuer-dich filters pass through unchanged', () => {
it('alle returns all items', () => {
const items = [makeItem(), makeItem({ kind: 'COMMENT_ADDED' })];
expect(applyClientFilter(items, 'alle')).toHaveLength(2);
});
it('hochgeladen passes through (server already filtered by kinds)', () => {
const items = [makeItem({ kind: 'FILE_UPLOADED' }), makeItem({ kind: 'COMMENT_ADDED' })];
expect(applyClientFilter(items, 'hochgeladen')).toHaveLength(2);
});
it('transkription passes through (server already filtered by kinds)', () => {
const items = [makeItem({ kind: 'TEXT_SAVED' })];
expect(applyClientFilter(items, 'transkription')).toHaveLength(1);
});
it('kommentare passes through (server already filtered by kinds)', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
expect(applyClientFilter(items, 'kommentare')).toHaveLength(1);
});
});
describe('fuer-dich applies youMentioned || youParticipated predicate', () => {
it('includes items where youMentioned is true', () => {
const items = [makeItem({ youMentioned: true })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
});
it('includes items where youParticipated is true', () => {
const items = [makeItem({ youParticipated: true })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
});
it('includes items where both flags are true', () => {
const items = [makeItem({ youMentioned: true, youParticipated: true })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
});
it('excludes items where neither flag is set', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(0);
});
it('MENTION_CREATED without youMentioned flag is excluded', () => {
// youMentioned is set by the backend only when this event is directed at the current user.
// Unlike the old feedFilters.ts, we no longer include MENTION_CREATED unconditionally —
// that incorrectly showed mentions directed at OTHER users.
const items = [
makeItem({ kind: 'MENTION_CREATED', youMentioned: false, youParticipated: false })
];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(0);
});
it('MENTION_CREATED with youMentioned true is included', () => {
const items = [makeItem({ kind: 'MENTION_CREATED', youMentioned: true })];
expect(applyClientFilter(items, 'fuer-dich')).toHaveLength(1);
});
it('filters mixed items to only personally relevant ones', () => {
const items = [
makeItem({ kind: 'FILE_UPLOADED' }),
makeItem({ kind: 'COMMENT_ADDED', youParticipated: true }),
makeItem({ kind: 'MENTION_CREATED', youMentioned: true }),
makeItem({ kind: 'TEXT_SAVED' })
];
const result = applyClientFilter(items, 'fuer-dich');
expect(result).toHaveLength(2);
expect(result[0].kind).toBe('COMMENT_ADDED');
expect(result[1].kind).toBe('MENTION_CREATED');
});
});
});

View File

@@ -0,0 +1,18 @@
import type { components } from '$lib/generated/api';
import type { FilterValue } from './+page.server';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
// All server-side filters (hochgeladen, transkription, kommentare) are already applied via
// kinds param — the server returns only matching items, so client-side is a no-op.
// fuer-dich is the only filter that requires client-side narrowing: youMentioned and
// youParticipated are user-scoped flags that cannot be expressed as a kinds filter.
export function applyClientFilter(
items: ActivityFeedItemDTO[],
filter: FilterValue
): ActivityFeedItemDTO[] {
if (filter === 'fuer-dich') {
return items.filter((item) => item.youMentioned || item.youParticipated);
}
return items;
}

View File

@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { load } from './+page.server';
const mockApi = {
GET: vi.fn()
};
vi.mock('$lib/api.server', () => ({
createApiClient: () => mockApi
}));
function buildUrl(search = ''): URL {
return new URL(`http://localhost/aktivitaeten${search}`);
}
function mockSuccess() {
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: [] } });
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('aktivitaeten/load — core', () => {
it('requests only unread notifications for Für-dich', async () => {
mockSuccess();
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('aktivitaeten/load — kinds param per filter', () => {
it('omits kinds for filter=alle (server defaults to ROLLUP_ELIGIBLE)', async () => {
mockSuccess();
await load({ fetch, url: buildUrl() } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: { query: { limit: 40 } }
});
});
it('omits kinds for filter=fuer-dich (client-side filtering on youMentioned/youParticipated)', async () => {
mockSuccess();
await load({ fetch, url: buildUrl('?filter=fuer-dich') } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: { query: { limit: 40 } }
});
});
it('sends kinds=FILE_UPLOADED for filter=hochgeladen', async () => {
mockSuccess();
await load({ fetch, url: buildUrl('?filter=hochgeladen') } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: { query: { limit: 40, kinds: ['FILE_UPLOADED'] } }
});
});
it('sends TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED for filter=transkription', async () => {
mockSuccess();
await load({ fetch, url: buildUrl('?filter=transkription') } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: {
query: {
limit: 40,
kinds: expect.arrayContaining(['TEXT_SAVED', 'BLOCK_REVIEWED', 'ANNOTATION_CREATED'])
}
}
});
const call = mockApi.GET.mock.calls.find(([p]) => p === '/api/dashboard/activity');
expect(call[1].params.query.kinds).toHaveLength(3);
});
it('sends COMMENT_ADDED, MENTION_CREATED for filter=kommentare', async () => {
mockSuccess();
await load({ fetch, url: buildUrl('?filter=kommentare') } as never);
expect(mockApi.GET).toHaveBeenCalledWith('/api/dashboard/activity', {
params: {
query: {
limit: 40,
kinds: expect.arrayContaining(['COMMENT_ADDED', 'MENTION_CREATED'])
}
}
});
const call = mockApi.GET.mock.calls.find(([p]) => p === '/api/dashboard/activity');
expect(call[1].params.query.kinds).toHaveLength(2);
});
});