diff --git a/frontend/src/routes/chronik/+page.svelte b/frontend/src/routes/chronik/+page.svelte index aa4c3679..719a1ab3 100644 --- a/frontend/src/routes/chronik/+page.svelte +++ b/frontend/src/routes/chronik/+page.svelte @@ -11,6 +11,7 @@ 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']; @@ -83,13 +84,7 @@ async function onMarkAllRead() { await notificationStore.markAllRead(); } -// fuer-dich cannot be expressed as a server-side kinds filter — youMentioned/youParticipated -// are user-scoped flags that require client-side narrowing -const displayFeed = $derived( - data.filter === 'fuer-dich' - ? data.activityFeed.filter((item) => item.youMentioned || item.youParticipated) - : data.activityFeed -); +const displayFeed = $derived(applyClientFilter(data.activityFeed, data.filter)); const isEmpty = $derived(displayFeed.length === 0); const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>( diff --git a/frontend/src/routes/chronik/clientFilter.test.ts b/frontend/src/routes/chronik/clientFilter.test.ts new file mode 100644 index 00000000..fe405f5c --- /dev/null +++ b/frontend/src/routes/chronik/clientFilter.test.ts @@ -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 { + 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'); + }); + }); +}); diff --git a/frontend/src/routes/chronik/clientFilter.ts b/frontend/src/routes/chronik/clientFilter.ts new file mode 100644 index 00000000..45c63901 --- /dev/null +++ b/frontend/src/routes/chronik/clientFilter.ts @@ -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; +}