refactor(chronik): remove client-side filter; add aria-live/aria-busy
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / OCR Service Tests (push) Successful in 36s
CI / Backend Unit Tests (push) Failing after 3m3s
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / OCR Service Tests (pull_request) Successful in 28s
CI / Backend Unit Tests (pull_request) Failing after 2m50s

- Delete feedFilters.ts and its 9 tests (dead code: server now filters)
- Remove activeFilter $state + $effect — read data.filter directly
- fuer-dich stays client-side via youMentioned/youParticipated predicate
- aria-live="polite" + aria-busy={!!navigating.type} on timeline region

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-21 22:26:27 +02:00
parent d42293d3f5
commit 330c6227bc
3 changed files with 18 additions and 141 deletions

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
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';
@@ -11,7 +11,6 @@ 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 { filterFeed } from './feedFilters';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
@@ -26,14 +25,6 @@ interface Props {
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.
@@ -74,7 +65,6 @@ const unread = $derived<NotificationItem[]>(
);
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);
@@ -93,7 +83,13 @@ async function onMarkAllRead() {
await notificationStore.markAllRead();
}
const displayFeed = $derived(filterFeed(data.activityFeed, activeFilter));
// 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 isEmpty = $derived(displayFeed.length === 0);
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(
@@ -120,15 +116,17 @@ function retry() {
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
<div class="mt-6">
<ChronikFilterPills value={activeFilter} onChange={onFilterChange} />
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
</div>
{#if isEmpty}
<div class="mt-8">
<ChronikEmptyState variant={emptyVariant} />
</div>
{:else}
<ChronikTimeline items={displayFeed} />
{/if}
<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

@@ -1,94 +0,0 @@
import { describe, expect, it } from 'vitest';
import { filterFeed } from './feedFilters';
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('filterFeed', () => {
describe('alle', () => {
it('returns all items regardless of kind', () => {
const items = [
makeItem({ kind: 'FILE_UPLOADED' }),
makeItem({ kind: 'COMMENT_ADDED' }),
makeItem({ kind: 'MENTION_CREATED' })
];
expect(filterFeed(items, 'alle')).toHaveLength(3);
});
});
describe('fuer-dich', () => {
it('includes MENTION_CREATED items', () => {
const items = [makeItem({ kind: 'MENTION_CREATED' })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
});
it('includes items where youMentioned is true', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED', youMentioned: true })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
});
it('includes items where youParticipated is true', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED', youParticipated: true })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(1);
});
it('excludes FILE_UPLOADED with no participation', () => {
const items = [makeItem({ kind: 'FILE_UPLOADED' })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(0);
});
it('excludes COMMENT_ADDED with no mention and no participation', () => {
const items = [makeItem({ kind: 'COMMENT_ADDED' })];
expect(filterFeed(items, 'fuer-dich')).toHaveLength(0);
});
});
describe('hochgeladen', () => {
it('includes only FILE_UPLOADED items', () => {
const items = [
makeItem({ kind: 'FILE_UPLOADED' }),
makeItem({ kind: 'COMMENT_ADDED', youParticipated: true })
];
expect(filterFeed(items, 'hochgeladen')).toHaveLength(1);
expect(filterFeed(items, 'hochgeladen')[0].kind).toBe('FILE_UPLOADED');
});
});
describe('transkription', () => {
it('includes TEXT_SAVED, BLOCK_REVIEWED, ANNOTATION_CREATED', () => {
const items = [
makeItem({ kind: 'TEXT_SAVED' }),
makeItem({ kind: 'BLOCK_REVIEWED' }),
makeItem({ kind: 'ANNOTATION_CREATED' }),
makeItem({ kind: 'FILE_UPLOADED' })
];
expect(filterFeed(items, 'transkription')).toHaveLength(3);
});
});
describe('kommentare', () => {
it('includes COMMENT_ADDED and MENTION_CREATED', () => {
const items = [
makeItem({ kind: 'COMMENT_ADDED' }),
makeItem({ kind: 'MENTION_CREATED' }),
makeItem({ kind: 'FILE_UPLOADED' })
];
expect(filterFeed(items, 'kommentare')).toHaveLength(2);
});
});
});

View File

@@ -1,27 +0,0 @@
import type { components } from '$lib/generated/api';
import type { FilterValue } from './+page.server';
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
export function filterFeed(
items: ActivityFeedItemDTO[],
filter: FilterValue
): ActivityFeedItemDTO[] {
switch (filter) {
case 'alle':
return items;
case 'fuer-dich':
return items.filter(
(i) => i.kind === 'MENTION_CREATED' || i.youMentioned || i.youParticipated
);
case 'hochgeladen':
return items.filter((i) => i.kind === 'FILE_UPLOADED');
case 'transkription':
return items.filter(
(i) =>
i.kind === 'TEXT_SAVED' || i.kind === 'BLOCK_REVIEWED' || i.kind === 'ANNOTATION_CREATED'
);
case 'kommentare':
return items.filter((i) => i.kind === 'COMMENT_ADDED' || i.kind === 'MENTION_CREATED');
}
}