refactor(notification): provide notification store via context + fixture

Converts the module-singleton notificationStore into a context-provided
store so its specs can drive it without mocking the module. notifications.svelte
now exports createNotificationStore() (the former singleton body), plus
provideNotificationStore()/getNotificationStore()/NOTIFICATION_KEY mirroring
the confirm service. Root +layout provides it; NotificationBell and the
Chronik page read it via getNotificationStore().

Tests:
- notifications.svelte.spec drives a fresh createNotificationStore() per test
  (replacing __resetForTest/__setNavigateForTest with setNavigate()).
- notification.test-fixture.svelte wraps the bell, provides the store, and
  exposes setNotifications(items) via onReady (option b).
- NotificationBell.svelte.spec asserts the announced unread count across the
  empty / single / many / error a11y states (AC#5), stubbing EventSource+fetch.
- aktivitaeten page spec injects a real store via render context.

Per the recorded Phase-2b decision (full context refactor). Part of #560.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-02 20:24:46 +02:00
committed by marcel
parent 29015ee864
commit ad067d2e0e
8 changed files with 356 additions and 206 deletions

View File

@@ -2,13 +2,13 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/shared/actions/clickOutside'; import { clickOutside } from '$lib/shared/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte'; import { getNotificationStore } from '$lib/notification/notifications.svelte';
import NotificationDropdown from './NotificationDropdown.svelte'; import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false); let open = $state(false);
let bellButtonEl: HTMLButtonElement | null = null; let bellButtonEl: HTMLButtonElement | null = null;
const stream = notificationStore; const stream = getNotificationStore();
async function toggleDropdown() { async function toggleDropdown() {
open = !open; open = !open;

View File

@@ -1,36 +1,43 @@
import { afterEach, describe, it, expect, vi } from 'vitest'; import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { m } from '$lib/paraglide/messages.js';
import type { NotificationItem } from '$lib/notification/notifications'; import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte';
import * as formsMock from '$mocks/$app/forms'; import * as formsMock from '$mocks/$app/forms';
import NotificationFixture from './notification.test-fixture.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock('$app/forms', () => ({ ...formsMock })); vi.mock('$app/forms', () => ({ ...formsMock }));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] })); // NotificationBell.onMount calls store.init(), which opens an EventSource and
// fetches the unread count. Stub both so no real network or 401 → /login
// navigation fires; the real store + provideNotificationStore() run otherwise.
class NoopEventSource {
static CLOSED = 2;
readyState = 0;
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
addEventListener() {}
close() {}
}
vi.mock('$lib/notification/notifications.svelte', () => ({ beforeEach(() => {
notificationStore: { vi.stubGlobal('EventSource', NoopEventSource);
get notifications() { vi.stubGlobal(
return mockNotificationList.value; 'fetch',
}, vi
get unreadCount() { .fn()
return mockNotificationList.value.length; .mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
}, );
optimisticMarkRead: vi.fn(), });
optimisticMarkAllRead: vi.fn(),
fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(),
destroy: vi.fn()
}
}));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
mockNotificationList.value = []; vi.unstubAllGlobals();
}); });
const tick = () => new Promise((r) => setTimeout(r, 0));
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({ const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n1', id: 'n1',
type: 'REPLY', type: 'REPLY',
@@ -44,30 +51,85 @@ const makeNotification = (overrides: Partial<NotificationItem> = {}): Notificati
...overrides ...overrides
}); });
describe('NotificationBell — cursor and tooltip', () => { type Api = { setNotifications: (items: NotificationItem[]) => void };
function renderBell(): Api {
let api: Api = { setNotifications: () => {} };
render(NotificationFixture, { onReady: (a: Api) => (api = a) });
return api;
}
function bellButton(): HTMLButtonElement {
return document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
}
function unreadBadge(): HTMLElement {
return bellButton().querySelector<HTMLElement>('[aria-live="polite"]')!;
}
describe('NotificationBell — rendering', () => {
it('bell button has cursor-pointer class', async () => { it('bell button has cursor-pointer class', async () => {
render(NotificationBell); renderBell();
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!; await tick();
expect(btn.classList.contains('cursor-pointer')).toBe(true); expect(bellButton().classList.contains('cursor-pointer')).toBe(true);
}); });
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = []; // AC#5: the bell's announced unread count must hold across the four a11y states.
render(NotificationBell); // The count is announced via the aria-live badge text and the button title /
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!; // aria-label; both must stay consistent as the store's notifications change.
expect(btn.getAttribute('title')).toBe('Benachrichtigungen'); describe('NotificationBell — announced unread count across a11y states', () => {
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); it('empty: announces no unread count and hides the live badge', async () => {
}); const { setNotifications } = renderBell();
await tick();
it('bell button title equals aria-label when unreadCount is 3', async () => { setNotifications([]);
mockNotificationList.value = [ await tick();
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }), const btn = bellButton();
makeNotification({ id: 'n3' }) expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
]; expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
render(NotificationBell); expect(unreadBadge().classList.contains('hidden')).toBe(true);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!; });
expect(btn.getAttribute('title')).toBe('3 ungelesene Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title')); it('single: announces one unread notification', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([makeNotification({ id: 'n1', read: false })]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 1 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(false);
expect(unreadBadge().textContent?.trim()).toBe('1');
});
it('many: announces the exact unread count', async () => {
const { setNotifications } = renderBell();
await tick();
setNotifications([
makeNotification({ id: 'n1', read: false }),
makeNotification({ id: 'n2', read: false }),
makeNotification({ id: 'n3', read: false })
]);
await tick();
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_unread_label({ count: 3 }));
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().textContent?.trim()).toBe('3');
});
it('error: degrades to a zero announced count when the unread-count load fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
renderBell();
await tick();
// A failed load leaves the count at 0; the bell still announces a valid,
// non-urgent state rather than a broken count.
const btn = bellButton();
expect(btn.getAttribute('title')).toBe(m.notification_bell_label());
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
expect(unreadBadge().classList.contains('hidden')).toBe(true);
}); });
}); });

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { provideNotificationStore, type NotificationItem } from './notifications.svelte';
import NotificationBell from './NotificationBell.svelte';
type Api = { setNotifications: (items: NotificationItem[]) => void };
let { onReady }: { onReady: (api: Api) => void } = $props();
const store = provideNotificationStore();
onReady({ setNotifications: store.setNotifications });
</script>
<NotificationBell />

View File

@@ -39,19 +39,20 @@ vi.stubGlobal('EventSource', MockEventSource);
const mockFetch = vi.fn(); const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch); vi.stubGlobal('fetch', mockFetch);
const { notificationStore, __resetForTest, __setNavigateForTest } = const { createNotificationStore } = await import('./notifications.svelte');
await import('./notifications.svelte');
let navigateSpy: ReturnType<typeof vi.fn>; let store: ReturnType<typeof createNotificationStore>;
let navigateSpy: ReturnType<typeof vi.fn<(url: string) => void>>;
beforeEach(() => { beforeEach(() => {
mockFetch.mockReset(); mockFetch.mockReset();
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 })); mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
lastEventSource = null; lastEventSource = null;
eventSourceCount = 0; eventSourceCount = 0;
navigateSpy = vi.fn(); navigateSpy = vi.fn<(url: string) => void>();
__setNavigateForTest(navigateSpy); // A fresh instance per test replaces the old __resetForTest() singleton reset.
__resetForTest(); store = createNotificationStore();
store.setNavigate(navigateSpy);
}); });
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem { function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
@@ -69,70 +70,70 @@ function makeNotification(overrides: Partial<NotificationItem> = {}): Notificati
}; };
} }
describe('notificationStore (singleton)', () => { describe('notification store', () => {
it('opens a single EventSource across multiple init() calls', () => { it('opens a single EventSource across multiple init() calls', () => {
notificationStore.init(); store.init();
notificationStore.init(); store.init();
notificationStore.init(); store.init();
expect(eventSourceCount).toBe(1); expect(eventSourceCount).toBe(1);
}); });
it('closes the EventSource only after every init() is matched with destroy()', () => { it('closes the EventSource only after every init() is matched with destroy()', () => {
notificationStore.init(); store.init();
notificationStore.init(); store.init();
const es = lastEventSource!; const es = lastEventSource!;
notificationStore.destroy(); store.destroy();
expect(es.close).not.toHaveBeenCalled(); expect(es.close).not.toHaveBeenCalled();
notificationStore.destroy(); store.destroy();
expect(es.close).toHaveBeenCalledTimes(1); expect(es.close).toHaveBeenCalledTimes(1);
}); });
it('reopens a fresh EventSource after full teardown', () => { it('reopens a fresh EventSource after full teardown', () => {
notificationStore.init(); store.init();
notificationStore.destroy(); store.destroy();
notificationStore.init(); store.init();
expect(eventSourceCount).toBe(2); expect(eventSourceCount).toBe(2);
}); });
it('SSE notification event prepends notification and increments unreadCount', () => { it('SSE notification event prepends notification and increments unreadCount', () => {
notificationStore.init(); store.init();
const notification = makeNotification({ id: 'sse-1', read: false }); const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification)); lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(notificationStore.notifications[0].id).toBe('sse-1'); expect(store.notifications[0].id).toBe('sse-1');
expect(notificationStore.unreadCount).toBe(1); expect(store.unreadCount).toBe(1);
}); });
it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => { it('optimisticMarkRead marks the notification read and decrements unreadCount without fetching', () => {
notificationStore.init(); store.init();
const notification = makeNotification({ id: 'sse-1', read: false }); const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification)); lastEventSource!.simulate('notification', JSON.stringify(notification));
mockFetch.mockReset(); // clear the fetchUnreadCount call from init mockFetch.mockReset(); // clear the fetchUnreadCount call from init
notificationStore.optimisticMarkRead('sse-1'); store.optimisticMarkRead('sse-1');
expect(notificationStore.notifications[0].read).toBe(true); expect(store.notifications[0].read).toBe(true);
expect(notificationStore.unreadCount).toBe(0); expect(store.unreadCount).toBe(0);
expect(mockFetch).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled();
}); });
it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => { it('optimisticMarkRead on an already-read notification does not decrement unreadCount below 0', () => {
notificationStore.init(); store.init();
const notification = makeNotification({ id: 'sse-1', read: true }); const notification = makeNotification({ id: 'sse-1', read: true });
lastEventSource!.simulate('notification', JSON.stringify(notification)); lastEventSource!.simulate('notification', JSON.stringify(notification));
notificationStore.optimisticMarkRead('sse-1'); store.optimisticMarkRead('sse-1');
expect(notificationStore.unreadCount).toBe(0); expect(store.unreadCount).toBe(0);
}); });
it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => { it('optimisticMarkAllRead resets unreadCount and marks all notifications read without fetching', () => {
notificationStore.init(); store.init();
lastEventSource!.simulate( lastEventSource!.simulate(
'notification', 'notification',
JSON.stringify(makeNotification({ id: 'n1', read: false })) JSON.stringify(makeNotification({ id: 'n1', read: false }))
@@ -143,18 +144,18 @@ describe('notificationStore (singleton)', () => {
); );
mockFetch.mockReset(); mockFetch.mockReset();
notificationStore.optimisticMarkAllRead(); store.optimisticMarkAllRead();
expect(notificationStore.unreadCount).toBe(0); expect(store.unreadCount).toBe(0);
expect(notificationStore.notifications.every((n) => n.read)).toBe(true); expect(store.notifications.every((n) => n.read)).toBe(true);
expect(mockFetch).not.toHaveBeenCalled(); expect(mockFetch).not.toHaveBeenCalled();
}); });
}); });
describe('notificationStore onerror handler', () => { describe('notification store onerror handler', () => {
it('redirects to /login when readyState is CLOSED and server returns 401', async () => { it('redirects to /login when readyState is CLOSED and server returns 401', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 401 })); mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
notificationStore.init(); store.init();
const es = lastEventSource!; const es = lastEventSource!;
es.readyState = MockEventSource.CLOSED; es.readyState = MockEventSource.CLOSED;
@@ -164,7 +165,7 @@ describe('notificationStore onerror handler', () => {
}); });
it('does not redirect when readyState is CLOSED and session is still valid', async () => { it('does not redirect when readyState is CLOSED and session is still valid', async () => {
notificationStore.init(); store.init();
const es = lastEventSource!; const es = lastEventSource!;
es.readyState = MockEventSource.CLOSED; es.readyState = MockEventSource.CLOSED;
@@ -174,7 +175,7 @@ describe('notificationStore onerror handler', () => {
}); });
it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => { it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => {
notificationStore.init(); store.init();
const es = lastEventSource!; const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING; es.readyState = MockEventSource.CONNECTING;
@@ -187,7 +188,7 @@ describe('notificationStore onerror handler', () => {
it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => { it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 401 })); mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
notificationStore.init(); store.init();
const es = lastEventSource!; const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING; es.readyState = MockEventSource.CONNECTING;
@@ -200,7 +201,7 @@ describe('notificationStore onerror handler', () => {
}); });
it('closes but does not redirect after threshold when session is still valid', async () => { it('closes but does not redirect after threshold when session is still valid', async () => {
notificationStore.init(); store.init();
const es = lastEventSource!; const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING; es.readyState = MockEventSource.CONNECTING;
@@ -213,7 +214,7 @@ describe('notificationStore onerror handler', () => {
}); });
it('resets error count after a successful reconnect (onopen)', async () => { it('resets error count after a successful reconnect (onopen)', async () => {
notificationStore.init(); store.init();
const es = lastEventSource!; const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING; es.readyState = MockEventSource.CONNECTING;

View File

@@ -1,121 +1,161 @@
import { getContext, setContext } from 'svelte';
import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications'; import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications';
export type { NotificationItem }; export type { NotificationItem };
let notifications = $state<NotificationItem[]>([]); export const NOTIFICATION_KEY = Symbol('notification');
let unreadCount = $state(0);
let eventSource: EventSource | null = null;
let refCount = 0;
let errorCount = 0;
let navigate: (url: string) => void = (url) => {
window.location.href = url;
};
async function fetchNotifications(): Promise<void> { export interface NotificationStore {
try { readonly notifications: NotificationItem[];
const res = await fetch('/api/notifications?size=10'); readonly unreadCount: number;
if (res.ok) { fetchNotifications(): Promise<void>;
const data = await res.json(); fetchUnreadCount(): Promise<void>;
notifications = data.content ?? []; optimisticMarkRead(id: string): void;
} optimisticMarkAllRead(): void;
} catch (e) { init(): void;
console.error('Failed to fetch notifications', e); destroy(): void;
} /** Test-only: seed the notification list and derive the unread count. */
setNotifications(items: NotificationItem[]): void;
/** Test-only: override the 401 → redirect side-effect. */
setNavigate(fn: (url: string) => void): void;
} }
async function fetchUnreadCount(): Promise<void> { export function createNotificationStore(): NotificationStore {
try { let notifications = $state<NotificationItem[]>([]);
const res = await fetch('/api/notifications/unread-count'); let unreadCount = $state(0);
if (res.ok) { let eventSource: EventSource | null = null;
const data = await res.json(); let refCount = 0;
unreadCount = data.count; let errorCount = 0;
} let navigate: (url: string) => void = (url) => {
} catch (e) { window.location.href = url;
console.error('Failed to fetch unread count', e);
}
}
function optimisticMarkRead(id: string): void {
const notification = notifications.find((n) => n.id === id);
if (notification && !notification.read) {
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
}
}
function optimisticMarkAllRead(): void {
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
}
function init(): void {
refCount += 1;
if (refCount > 1) return;
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent((e as MessageEvent).data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
eventSource.onopen = () => {
fetchUnreadCount();
errorCount = 0;
}; };
eventSource.onerror = async () => {
if (eventSource?.readyState === EventSource.CLOSED) { async function fetchNotifications(): Promise<void> {
const res = await fetch('/api/notifications/unread-count'); try {
if (res.status === 401) navigate('/login'); const res = await fetch('/api/notifications?size=10');
return; if (res.ok) {
const data = await res.json();
notifications = data.content ?? [];
}
} catch (e) {
console.error('Failed to fetch notifications', e);
} }
errorCount += 1; }
if (errorCount >= 3) {
async function fetchUnreadCount(): Promise<void> {
try {
const res = await fetch('/api/notifications/unread-count');
if (res.ok) {
const data = await res.json();
unreadCount = data.count;
}
} catch (e) {
console.error('Failed to fetch unread count', e);
}
}
function optimisticMarkRead(id: string): void {
const notification = notifications.find((n) => n.id === id);
if (notification && !notification.read) {
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
}
}
function optimisticMarkAllRead(): void {
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
}
function init(): void {
refCount += 1;
if (refCount > 1) return;
fetchUnreadCount();
eventSource = new EventSource('/api/notifications/stream');
eventSource.addEventListener('notification', (e) => {
const notification = parseNotificationEvent((e as MessageEvent).data);
if (!notification) return;
notifications = [notification, ...notifications];
if (!notification.read) unreadCount += 1;
});
eventSource.onopen = () => {
fetchUnreadCount();
errorCount = 0;
};
eventSource.onerror = async () => {
if (eventSource?.readyState === EventSource.CLOSED) {
const res = await fetch('/api/notifications/unread-count');
if (res.status === 401) navigate('/login');
return;
}
errorCount += 1;
if (errorCount >= 3) {
eventSource?.close();
eventSource = null;
errorCount = 0;
const res = await fetch('/api/notifications/unread-count');
if (res.status === 401) navigate('/login');
}
};
}
function destroy(): void {
if (refCount === 0) return;
refCount -= 1;
if (refCount === 0) {
eventSource?.close(); eventSource?.close();
eventSource = null; eventSource = null;
errorCount = 0; }
const res = await fetch('/api/notifications/unread-count'); }
if (res.status === 401) navigate('/login');
return {
get notifications() {
return notifications;
},
get unreadCount() {
return unreadCount;
},
fetchNotifications,
fetchUnreadCount,
optimisticMarkRead,
optimisticMarkAllRead,
init,
destroy,
setNotifications(items: NotificationItem[]): void {
notifications = items;
unreadCount = items.filter((n) => !n.read).length;
},
setNavigate(fn: (url: string) => void): void {
navigate = fn;
} }
}; };
} }
function destroy(): void { /**
if (refCount === 0) return; * Create a notification store and put it on the context. Call once high in the
refCount -= 1; * tree (root +layout.svelte). Descendants read it via getNotificationStore().
if (refCount === 0) { */
eventSource?.close(); export function provideNotificationStore(): NotificationStore {
eventSource = null; const store = createNotificationStore();
setContext(NOTIFICATION_KEY, store);
return store;
}
export function getNotificationStore(): NotificationStore {
let store: NotificationStore | undefined;
try {
store = getContext<NotificationStore>(NOTIFICATION_KEY);
} catch {
throw new Error(
'NotificationStore not found — call provideNotificationStore() in +layout.svelte'
);
} }
if (!store)
throw new Error(
'NotificationStore not found — call provideNotificationStore() in +layout.svelte'
);
return store;
} }
export function __resetForTest(): void {
eventSource?.close();
eventSource = null;
refCount = 0;
errorCount = 0;
notifications = [];
unreadCount = 0;
}
export function __setNavigateForTest(fn: (url: string) => void): void {
navigate = fn;
}
export const notificationStore = {
get notifications() {
return notifications;
},
get unreadCount() {
return unreadCount;
},
fetchNotifications,
fetchUnreadCount,
optimisticMarkRead,
optimisticMarkAllRead,
init,
destroy
};

View File

@@ -10,6 +10,7 @@ import AppNav from './AppNav.svelte';
import UserMenu from './UserMenu.svelte'; import UserMenu from './UserMenu.svelte';
import ConfirmDialog from '$lib/shared/primitives/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/shared/primitives/ConfirmDialog.svelte';
import { provideConfirmService } from '$lib/shared/services/confirm.svelte'; import { provideConfirmService } from '$lib/shared/services/confirm.svelte';
import { provideNotificationStore } from '$lib/notification/notifications.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte'; import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
let { children, data } = $props(); let { children, data } = $props();
@@ -18,6 +19,11 @@ let { children, data } = $props();
// ConfirmDialog below reads it via getConfirmService() and renders the <dialog>. // ConfirmDialog below reads it via getConfirmService() and renders the <dialog>.
provideConfirmService(); provideConfirmService();
// Provide the notification store to the tree. NotificationBell (header) and the
// Chronik page read it via getNotificationStore(); the bell drives the SSE
// lifecycle through init()/destroy() on mount.
provideNotificationStore();
// Auto-clear the bulk-selection store when the user leaves the routes that // Auto-clear the bulk-selection store when the user leaves the routes that
// surface the BulkSelectionBar. Without this the selection silently follows // surface the BulkSelectionBar. Without this the selection silently follows
// the user to /persons / /admin etc. and reappears as a stale 3-doc count // the user to /persons / /admin etc. and reappears as a stale 3-doc count

View File

@@ -3,7 +3,10 @@ import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page, navigating } 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/notification/notifications.svelte'; import {
getNotificationStore,
type NotificationItem
} from '$lib/notification/notifications.svelte';
import ChronikFuerDichBox from '$lib/activity/ChronikFuerDichBox.svelte'; import ChronikFuerDichBox from '$lib/activity/ChronikFuerDichBox.svelte';
import ChronikFilterPills from '$lib/activity/ChronikFilterPills.svelte'; import ChronikFilterPills from '$lib/activity/ChronikFilterPills.svelte';
import ChronikTimeline from '$lib/activity/ChronikTimeline.svelte'; import ChronikTimeline from '$lib/activity/ChronikTimeline.svelte';
@@ -26,9 +29,11 @@ interface Props {
const { data }: Props = $props(); const { data }: Props = $props();
// Prefer the live SSE singleton for unread items so newly arriving mentions const notificationStore = getNotificationStore();
// Prefer the live SSE store 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 store hasn't populated yet.
onMount(() => { onMount(() => {
notificationStore.init(); notificationStore.init();
}); });
@@ -59,7 +64,7 @@ const seedUnread = $derived<NotificationItem[]>(
})) }))
); );
// If the singleton has any data (including zero after mark-all), trust it; // If the store has any data (including zero after mark-all), trust it;
// otherwise fall back to the SSR-seeded unread set. // otherwise fall back to the SSR-seeded unread set.
const unread = $derived<NotificationItem[]>( const unread = $derived<NotificationItem[]>(
notificationStore.notifications.length > 0 ? liveUnread : seedUnread notificationStore.notifications.length > 0 ? liveUnread : seedUnread

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import { createNotificationStore, NOTIFICATION_KEY } from '$lib/notification/notifications.svelte';
const mockNavigating = { type: null }; const mockNavigating = { type: null };
const mockPage = { url: new URL('http://localhost/aktivitaeten') }; const mockPage = { url: new URL('http://localhost/aktivitaeten') };
@@ -28,19 +29,35 @@ vi.mock('$app/navigation', () => ({
onNavigate: () => () => {} onNavigate: () => () => {}
})); }));
vi.mock('$lib/notification/notifications.svelte', () => ({ // The Chronik page's onMount calls store.init(), opening an EventSource and
notificationStore: { // fetching the unread count. Stub both so no real network / 401 → /login fires.
notifications: [], class NoopEventSource {
init: vi.fn(), static CLOSED = 2;
destroy: vi.fn(), readyState = 0;
markRead: vi.fn(), onopen: (() => void) | null = null;
markAllRead: vi.fn() onerror: (() => void) | null = null;
} addEventListener() {}
})); close() {}
}
beforeEach(() => {
vi.stubGlobal('EventSource', NoopEventSource);
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ count: 0, content: [] }), { status: 200 }))
);
});
const { default: AktivitaetenPage } = await import('./+page.svelte'); const { default: AktivitaetenPage } = await import('./+page.svelte');
afterEach(cleanup); afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const notificationContext = () => new Map([[NOTIFICATION_KEY, createNotificationStore()]]);
const baseData = (overrides: Record<string, unknown> = {}) => ({ const baseData = (overrides: Record<string, unknown> = {}) => ({
filter: 'alle' as const, filter: 'alle' as const,
@@ -52,13 +69,16 @@ const baseData = (overrides: Record<string, unknown> = {}) => ({
describe('aktivitaeten page', () => { describe('aktivitaeten page', () => {
it('renders the page heading', async () => { it('renders the page heading', async () => {
render(AktivitaetenPage, { props: { data: baseData() } }); render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
await expect.element(page.getByRole('heading', { name: /aktivitäten/i })).toBeVisible(); await expect.element(page.getByRole('heading', { name: /aktivitäten/i })).toBeVisible();
}); });
it('renders the error card when loadError is "activity"', async () => { it('renders the error card when loadError is "activity"', async () => {
render(AktivitaetenPage, { props: { data: baseData({ loadError: 'activity' }) } }); render(AktivitaetenPage, {
context: notificationContext(),
props: { data: baseData({ loadError: 'activity' }) }
});
// ChronikErrorCard renders some retry mechanism // ChronikErrorCard renders some retry mechanism
const main = document.querySelector('main'); const main = document.querySelector('main');
@@ -69,7 +89,7 @@ describe('aktivitaeten page', () => {
}); });
it('renders the FuerDichBox and FilterPills when loadError is null', async () => { it('renders the FuerDichBox and FilterPills when loadError is null', async () => {
render(AktivitaetenPage, { props: { data: baseData() } }); render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
// FuerDichBox shows the inbox-zero state when no unread // FuerDichBox shows the inbox-zero state when no unread
const fuerDich = document.querySelector('[data-testid="chronik-inbox-zero"]'); const fuerDich = document.querySelector('[data-testid="chronik-inbox-zero"]');
@@ -81,7 +101,7 @@ describe('aktivitaeten page', () => {
}); });
it('renders the first-run empty state when activityFeed is empty', async () => { it('renders the first-run empty state when activityFeed is empty', async () => {
render(AktivitaetenPage, { props: { data: baseData() } }); render(AktivitaetenPage, { context: notificationContext(), props: { data: baseData() } });
const empty = document.querySelector('[data-testid="chronik-empty-state"]'); const empty = document.querySelector('[data-testid="chronik-empty-state"]');
expect(empty?.getAttribute('data-variant')).toBe('first-run'); expect(empty?.getAttribute('data-variant')).toBe('first-run');
@@ -89,6 +109,7 @@ describe('aktivitaeten page', () => {
it('renders the filter-empty empty state when feed has items but filter rules out all', async () => { it('renders the filter-empty empty state when feed has items but filter rules out all', async () => {
render(AktivitaetenPage, { render(AktivitaetenPage, {
context: notificationContext(),
props: { props: {
data: baseData({ data: baseData({
filter: 'fuer-dich' as const, filter: 'fuer-dich' as const,
@@ -114,6 +135,7 @@ describe('aktivitaeten page', () => {
it('renders the timeline when displayFeed is non-empty', async () => { it('renders the timeline when displayFeed is non-empty', async () => {
render(AktivitaetenPage, { render(AktivitaetenPage, {
context: notificationContext(),
props: { props: {
data: baseData({ data: baseData({
filter: 'alle' as const, filter: 'alle' as const,
@@ -142,6 +164,7 @@ describe('aktivitaeten page', () => {
it('renders without crashing when filter is set to a non-default value', async () => { it('renders without crashing when filter is set to a non-default value', async () => {
render(AktivitaetenPage, { render(AktivitaetenPage, {
context: notificationContext(),
props: { data: baseData({ filter: 'transkription' as const }) } props: { data: baseData({ filter: 'transkription' as const }) }
}); });