feat(dashboard): add kinds CSV query param to /api/dashboard/activity
#302
@@ -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>
|
||||
|
||||
@@ -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