refactor: move notification domain to lib/notification/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:22:02 +02:00
parent 8ff5d6f842
commit 051d2f246e
11 changed files with 11 additions and 11 deletions

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { notificationStore } from '$lib/notification/notifications.svelte';
import { buildCommentHref } from '$lib/utils/commentDeepLink';
import NotificationDropdown from './NotificationDropdown.svelte';
let open = $state(false);
let bellButtonEl: HTMLButtonElement | null = null;
const stream = notificationStore;
async function toggleDropdown() {
open = !open;
if (open) {
await stream.fetchNotifications();
setTimeout(() => {
const firstBtn = document.querySelector<HTMLButtonElement>(
'[role="dialog"] button, [role="dialog"] a'
);
firstBtn?.focus();
}, 0);
}
}
function closeDropdown() {
open = false;
bellButtonEl?.focus();
}
async function handleMarkRead(notification: Parameters<typeof stream.markRead>[0]) {
await stream.markRead(notification);
const url = buildCommentHref(
notification.documentId,
notification.referenceId,
notification.annotationId
);
closeDropdown();
goto(url);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && open) {
event.stopPropagation();
closeDropdown();
}
}
const bellLabel = $derived(
stream.unreadCount > 0
? m.notification_bell_unread_label({ count: stream.unreadCount })
: m.notification_bell_label()
);
function attachBellButton(node: HTMLButtonElement) {
bellButtonEl = node;
return () => {
bellButtonEl = null;
};
}
onMount(() => {
stream.init();
});
onDestroy(() => {
stream.destroy();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="relative" use:clickOutside onclickoutside={() => { if (open) closeDropdown(); }}>
<!-- Bell button -->
<button
{@attach attachBellButton}
type="button"
onclick={toggleDropdown}
aria-label={bellLabel}
title={bellLabel}
aria-expanded={open}
aria-haspopup="true"
class="relative cursor-pointer rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<!-- Persistent aria-live wrapper — always in DOM so live region history is preserved -->
<span
aria-live="polite"
aria-atomic="true"
class="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-fg {stream.unreadCount > 0 ? '' : 'hidden'}"
>
{stream.unreadCount}
</span>
</button>
{#if open}
<NotificationDropdown
notifications={stream.notifications}
onMarkRead={handleMarkRead}
onMarkAllRead={stream.markAllRead}
onClose={closeDropdown}
/>
{/if}
</div>

View File

@@ -0,0 +1,110 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import type { NotificationItem } from '$lib/notification/notifications';
import NotificationBell from './NotificationBell.svelte';
const gotoMock = vi.hoisted(() => vi.fn());
vi.mock('$app/navigation', () => ({ goto: gotoMock, beforeNavigate: vi.fn() }));
const mockMarkRead = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
const mockNotificationList = vi.hoisted((): { value: NotificationItem[] } => ({ value: [] }));
vi.mock('$lib/notification/notifications.svelte', () => ({
notificationStore: {
get notifications() {
return mockNotificationList.value;
},
get unreadCount() {
return mockNotificationList.value.length;
},
markRead: mockMarkRead,
fetchNotifications: vi.fn().mockResolvedValue(undefined),
init: vi.fn(),
destroy: vi.fn(),
markAllRead: vi.fn()
}
}));
afterEach(() => {
cleanup();
gotoMock.mockClear();
mockMarkRead.mockClear();
mockNotificationList.value = [];
});
const makeNotification = (overrides: Partial<NotificationItem> = {}): NotificationItem => ({
id: 'n1',
type: 'REPLY',
documentId: 'doc-1',
referenceId: 'ref-1',
annotationId: null,
read: false,
createdAt: '2026-04-21T10:00:00Z',
actorName: 'Anna',
documentTitle: 'Test Doc',
...overrides
});
async function openDropdownAndClickFirstNotification() {
const bellButton = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
bellButton.click();
await vi.waitFor(() => {
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
});
const notifButton = document.querySelector<HTMLButtonElement>('[role="list"] button')!;
notifButton.click();
}
describe('NotificationBell — cursor and tooltip', () => {
it('bell button has cursor-pointer class', async () => {
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.classList.contains('cursor-pointer')).toBe(true);
});
it('bell button title equals aria-label when unreadCount is 0', async () => {
mockNotificationList.value = [];
render(NotificationBell);
const btn = document.querySelector<HTMLButtonElement>('button[aria-haspopup="true"]')!;
expect(btn.getAttribute('title')).toBe('Benachrichtigungen');
expect(btn.getAttribute('aria-label')).toBe(btn.getAttribute('title'));
});
it('bell button title equals aria-label when unreadCount is 3', async () => {
mockNotificationList.value = [
makeNotification({ id: 'n1' }),
makeNotification({ id: 'n2' }),
makeNotification({ id: 'n3' })
];
render(NotificationBell);
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'));
});
});
describe('NotificationBell', () => {
it('handleMarkRead navigates to URL including annotationId when notification has annotationId', async () => {
mockNotificationList.value = [makeNotification({ annotationId: 'annot-1' })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith(
'/documents/doc-1?commentId=ref-1&annotationId=annot-1'
);
});
});
it('handleMarkRead navigates to commentId-only URL when annotationId is absent', async () => {
mockNotificationList.value = [makeNotification({ annotationId: null })];
render(NotificationBell);
await openDropdownAndClickFirstNotification();
await vi.waitFor(() => {
expect(gotoMock).toHaveBeenCalledWith('/documents/doc-1?commentId=ref-1');
});
});
});

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { relativeTime } from '$lib/utils/time';
import type { NotificationItem } from '$lib/notification/notifications.svelte';
type Props = {
notifications: NotificationItem[];
onMarkRead: (notification: NotificationItem) => void;
onMarkAllRead: () => void;
onClose: () => void;
};
let { notifications, onMarkRead, onMarkAllRead, onClose }: Props = $props();
</script>
<div
role="dialog"
aria-modal="true"
aria-label={m.notification_bell_label()}
class="absolute right-0 z-50 mt-2 w-80 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-line px-4 py-3">
<span class="text-xs font-bold tracking-widest text-ink-2 uppercase">
{m.notification_bell_label()}
</span>
{#if notifications.length > 0}
<button
type="button"
onclick={onMarkAllRead}
class="text-xs font-medium text-ink-3 transition-colors hover:text-ink"
>
{m.notification_mark_all_read()}
</button>
{/if}
</div>
<!-- Notification list -->
{#if notifications.length === 0}
<!-- Empty state -->
<div class="flex flex-col items-center gap-2 px-4 py-8 text-center text-sm text-ink-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-ink-3 opacity-40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span>{m.notification_empty()}</span>
</div>
{:else}
<ul role="list" class="max-h-[24rem] overflow-y-auto">
{#each notifications as notification (notification.id)}
<li>
<button
type="button"
onclick={() => onMarkRead(notification)}
class="flex w-full cursor-pointer items-start gap-3 border-b border-line px-4 py-3 text-left last:border-b-0 hover:bg-canvas
{!notification.read ? 'bg-accent-bg/20' : ''}"
>
<!-- Type icon -->
<span class="mt-0.5 shrink-0 text-ink-3" aria-hidden="true">
{#if notification.type === 'REPLY'}
<!-- Reply icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
{:else}
<!-- Mention icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
{/if}
</span>
<!-- Text + time -->
<div class="min-w-0 flex-1">
<p class="text-sm leading-snug text-ink">
{notification.type === 'REPLY'
? m.notification_type_reply({ actor: notification.actorName })
: m.notification_type_mention({ actor: notification.actorName })}
</p>
<p class="mt-1 text-xs text-ink-3">{relativeTime(notification.createdAt)}</p>
</div>
<!-- Unread dot -->
{#if !notification.read}
<span
class="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary"
aria-label={m.notification_unread()}
></span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
<div class="border-t border-line px-4 py-2">
<a
href="/aktivitaeten"
onclick={onClose}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.chronik_view_all()}
</a>
</div>
</div>

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { parseNotificationEvent } from '$lib/notification/notifications';
describe('parseNotificationEvent', () => {
const valid = {
id: '00000000-0000-0000-0000-000000000001',
documentId: '00000000-0000-0000-0000-000000000002',
actorName: 'Anna Müller',
type: 'MENTION',
referenceId: '00000000-0000-0000-0000-000000000003',
annotationId: null,
read: false,
createdAt: '2024-06-15T10:00:00',
documentTitle: 'Geburtsurkunde Opa Karl'
};
it('should return parsed object for a valid payload', () => {
const result = parseNotificationEvent(JSON.stringify(valid));
expect(result).not.toBeNull();
expect(result?.id).toBe(valid.id);
expect(result?.actorName).toBe('Anna Müller');
});
it('should return null for invalid JSON', () => {
expect(parseNotificationEvent('not-json')).toBeNull();
});
it('should return null when id is missing', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, ...noId } = valid;
expect(parseNotificationEvent(JSON.stringify(noId))).toBeNull();
});
it('should return null when documentId is missing', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { documentId, ...noDocId } = valid;
expect(parseNotificationEvent(JSON.stringify(noDocId))).toBeNull();
});
it('should return null when actorName is missing', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { actorName, ...noActor } = valid;
expect(parseNotificationEvent(JSON.stringify(noActor))).toBeNull();
});
it('should return null for unknown notification type', () => {
expect(parseNotificationEvent(JSON.stringify({ ...valid, type: 'UNKNOWN' }))).toBeNull();
});
it('should accept REPLY as a valid type', () => {
const result = parseNotificationEvent(JSON.stringify({ ...valid, type: 'REPLY' }));
expect(result).not.toBeNull();
expect(result?.type).toBe('REPLY');
});
});

View File

@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { NotificationItem } from '$lib/notification/notifications';
let lastEventSource: MockEventSource | null = null;
let eventSourceCount = 0;
class MockEventSource {
static CONNECTING = 0;
static OPEN = 1;
static CLOSED = 2;
readyState = MockEventSource.CONNECTING;
onopen: (() => void) | null = null;
onerror: (() => void | Promise<void>) | null = null;
close = vi.fn();
private listeners: Record<string, ((e: MessageEvent) => void)[]> = {};
constructor() {
eventSourceCount += 1;
// eslint-disable-next-line @typescript-eslint/no-this-alias
lastEventSource = this;
}
addEventListener(type: string, fn: (e: MessageEvent) => void) {
if (!this.listeners[type]) this.listeners[type] = [];
this.listeners[type].push(fn);
}
simulate(type: string, data: string) {
const event = new MessageEvent(type, { data });
for (const fn of this.listeners[type] ?? []) {
fn(event);
}
}
}
vi.stubGlobal('EventSource', MockEventSource);
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
const { notificationStore, __resetForTest, __setNavigateForTest } =
await import('./notifications.svelte');
let navigateSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue(new Response(JSON.stringify({ count: 0 }), { status: 200 }));
lastEventSource = null;
eventSourceCount = 0;
navigateSpy = vi.fn();
__setNavigateForTest(navigateSpy);
__resetForTest();
});
function makeNotification(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 'n1',
type: 'REPLY',
actorName: 'Hans',
documentId: 'doc-1',
documentTitle: null,
referenceId: 'ref-1',
annotationId: null,
read: false,
createdAt: new Date().toISOString(),
...overrides
};
}
describe('notificationStore (singleton)', () => {
it('opens a single EventSource across multiple init() calls', () => {
notificationStore.init();
notificationStore.init();
notificationStore.init();
expect(eventSourceCount).toBe(1);
});
it('closes the EventSource only after every init() is matched with destroy()', () => {
notificationStore.init();
notificationStore.init();
const es = lastEventSource!;
notificationStore.destroy();
expect(es.close).not.toHaveBeenCalled();
notificationStore.destroy();
expect(es.close).toHaveBeenCalledTimes(1);
});
it('reopens a fresh EventSource after full teardown', () => {
notificationStore.init();
notificationStore.destroy();
notificationStore.init();
expect(eventSourceCount).toBe(2);
});
it('SSE notification event prepends notification and increments unreadCount', () => {
notificationStore.init();
const notification = makeNotification({ id: 'sse-1', read: false });
lastEventSource!.simulate('notification', JSON.stringify(notification));
expect(notificationStore.notifications[0].id).toBe('sse-1');
expect(notificationStore.unreadCount).toBe(1);
});
it('markAllRead resets unreadCount', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 200 }));
await notificationStore.markAllRead();
expect(mockFetch).toHaveBeenCalledWith('/api/notifications/read-all', { method: 'POST' });
expect(notificationStore.unreadCount).toBe(0);
});
});
describe('notificationStore onerror handler', () => {
it('redirects to /login when readyState is CLOSED and server returns 401', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
notificationStore.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CLOSED;
await es.onerror?.();
expect(navigateSpy).toHaveBeenCalledWith('/login');
});
it('does not redirect when readyState is CLOSED and session is still valid', async () => {
notificationStore.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CLOSED;
await es.onerror?.();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('does not close or redirect before the error threshold when readyState is CONNECTING', async () => {
notificationStore.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING;
await es.onerror?.();
await es.onerror?.();
expect(es.close).not.toHaveBeenCalled();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('closes and redirects after 3 consecutive CONNECTING errors when session returns 401', async () => {
mockFetch.mockResolvedValue(new Response(null, { status: 401 }));
notificationStore.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING;
await es.onerror?.();
await es.onerror?.();
await es.onerror?.();
expect(es.close).toHaveBeenCalledTimes(1);
expect(navigateSpy).toHaveBeenCalledWith('/login');
});
it('closes but does not redirect after threshold when session is still valid', async () => {
notificationStore.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING;
await es.onerror?.();
await es.onerror?.();
await es.onerror?.();
expect(es.close).toHaveBeenCalledTimes(1);
expect(navigateSpy).not.toHaveBeenCalled();
});
it('resets error count after a successful reconnect (onopen)', async () => {
notificationStore.init();
const es = lastEventSource!;
es.readyState = MockEventSource.CONNECTING;
// Two errors — not yet at threshold
await es.onerror?.();
await es.onerror?.();
// Successful reconnect resets counter
es.onopen?.();
// Two more errors — should still be below threshold
await es.onerror?.();
await es.onerror?.();
expect(es.close).not.toHaveBeenCalled();
expect(navigateSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,130 @@
import { type NotificationItem, parseNotificationEvent } from '$lib/notification/notifications';
export type { NotificationItem };
let notifications = $state<NotificationItem[]>([]);
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> {
try {
const res = await fetch('/api/notifications?size=10');
if (res.ok) {
const data = await res.json();
notifications = data.content ?? [];
}
} catch (e) {
console.error('Failed to fetch notifications', e);
}
}
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);
}
}
async function markRead(notification: NotificationItem): Promise<void> {
if (!notification.read) {
try {
await fetch(`/api/notifications/${notification.id}/read`, { method: 'PATCH' });
notification.read = true;
unreadCount = Math.max(0, unreadCount - 1);
} catch (e) {
console.error('Failed to mark notification as read', e);
}
}
}
async function markAllRead(): Promise<void> {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
for (const n of notifications) {
n.read = true;
}
unreadCount = 0;
} catch (e) {
console.error('Failed to mark all notifications as read', e);
}
}
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 = null;
}
}
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,
markRead,
markAllRead,
init,
destroy
};

View File

@@ -0,0 +1,31 @@
export type NotificationItem = {
id: string;
type: 'REPLY' | 'MENTION';
documentId: string;
referenceId: string;
annotationId: string | null;
read: boolean;
createdAt: string;
actorName: string;
documentTitle: string | null;
};
export { relativeTime } from '$lib/utils/time';
export function parseNotificationEvent(raw: string): NotificationItem | null {
try {
const parsed = JSON.parse(raw);
if (
typeof parsed.id !== 'string' ||
typeof parsed.documentId !== 'string' ||
typeof parsed.actorName !== 'string' ||
!['REPLY', 'MENTION'].includes(parsed.type)
) {
console.warn('Unexpected SSE payload shape:', parsed);
return null;
}
return parsed as NotificationItem;
} catch {
return null;
}
}