feat(chronik): rename route and heading to Aktivitäten
/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:
67
frontend/src/routes/aktivitaeten/+page.server.ts
Normal file
67
frontend/src/routes/aktivitaeten/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
127
frontend/src/routes/aktivitaeten/+page.svelte
Normal file
127
frontend/src/routes/aktivitaeten/+page.svelte
Normal 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>
|
||||
94
frontend/src/routes/aktivitaeten/clientFilter.test.ts
Normal file
94
frontend/src/routes/aktivitaeten/clientFilter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
18
frontend/src/routes/aktivitaeten/clientFilter.ts
Normal file
18
frontend/src/routes/aktivitaeten/clientFilter.ts
Normal 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;
|
||||
}
|
||||
135
frontend/src/routes/aktivitaeten/page.server.spec.ts
Normal file
135
frontend/src/routes/aktivitaeten/page.server.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user