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
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:
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
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 * as m from '$lib/paraglide/messages.js';
|
||||||
import { notificationStore, type NotificationItem } from '$lib/stores/notifications.svelte';
|
import { notificationStore, type NotificationItem } from '$lib/stores/notifications.svelte';
|
||||||
import ChronikFuerDichBox from '$lib/components/chronik/ChronikFuerDichBox.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 ChronikErrorCard from '$lib/components/chronik/ChronikErrorCard.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import type { FilterValue } from './+page.server';
|
import type { FilterValue } from './+page.server';
|
||||||
import { filterFeed } from './feedFilters';
|
|
||||||
|
|
||||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||||
|
|
||||||
@@ -26,14 +25,6 @@ interface Props {
|
|||||||
|
|
||||||
const { data }: Props = $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
|
// 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
|
// prepend without a reload. On first mount, seed from the server-loaded unread
|
||||||
// set if the singleton hasn't populated yet.
|
// set if the singleton hasn't populated yet.
|
||||||
@@ -74,7 +65,6 @@ const unread = $derived<NotificationItem[]>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function onFilterChange(v: FilterValue) {
|
async function onFilterChange(v: FilterValue) {
|
||||||
activeFilter = v;
|
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
if (v === 'alle') url.searchParams.delete('filter');
|
if (v === 'alle') url.searchParams.delete('filter');
|
||||||
else url.searchParams.set('filter', v);
|
else url.searchParams.set('filter', v);
|
||||||
@@ -93,7 +83,13 @@ async function onMarkAllRead() {
|
|||||||
await notificationStore.markAllRead();
|
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 isEmpty = $derived(displayFeed.length === 0);
|
||||||
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(
|
const emptyVariant = $derived<'first-run' | 'filter-empty' | 'inbox-zero'>(
|
||||||
@@ -120,15 +116,17 @@ function retry() {
|
|||||||
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
<ChronikFuerDichBox unread={unread} onMarkRead={onMarkRead} onMarkAllRead={onMarkAllRead} />
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<ChronikFilterPills value={activeFilter} onChange={onFilterChange} />
|
<ChronikFilterPills value={data.filter} onChange={onFilterChange} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isEmpty}
|
<div aria-live="polite" aria-atomic="false" aria-busy={!!navigating.type}>
|
||||||
<div class="mt-8">
|
{#if isEmpty}
|
||||||
<ChronikEmptyState variant={emptyVariant} />
|
<div class="mt-8">
|
||||||
</div>
|
<ChronikEmptyState variant={emptyVariant} />
|
||||||
{:else}
|
</div>
|
||||||
<ChronikTimeline items={displayFeed} />
|
{:else}
|
||||||
{/if}
|
<ChronikTimeline items={displayFeed} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user