diff --git a/frontend/src/lib/components/NotificationBell.svelte b/frontend/src/lib/components/NotificationBell.svelte index f8edc540..cb62dbfa 100644 --- a/frontend/src/lib/components/NotificationBell.svelte +++ b/frontend/src/lib/components/NotificationBell.svelte @@ -2,17 +2,11 @@ import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { m } from '$lib/paraglide/messages.js'; - -type NotificationItem = { - id: string; - type: 'REPLY' | 'MENTION'; - documentId: string; - referenceId: string; - annotationId: string | null; - read: boolean; - createdAt: string; - actorName: string; -}; +import { + type NotificationItem, + relativeTime, + parseNotificationEvent +} from '$lib/utils/notifications'; let notifications: NotificationItem[] = $state([]); let unreadCount: number = $state(0); @@ -131,23 +125,12 @@ function attachClickOutside(node: HTMLElement) { }; } -function relativeTime(isoString: string): string { - const diffMs = Date.now() - new Date(isoString).getTime(); - const diffMin = Math.floor(diffMs / 60000); - const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); - if (diffMin < 1) return rtf.format(0, 'minute'); - if (diffMin < 60) return rtf.format(-diffMin, 'minute'); - const diffH = Math.floor(diffMin / 60); - if (diffH < 24) return rtf.format(-diffH, 'hour'); - const diffD = Math.floor(diffH / 24); - return rtf.format(-diffD, 'day'); -} - onMount(() => { fetchUnreadCount(); eventSource = new EventSource('/api/notifications/stream'); eventSource.addEventListener('notification', (e) => { - const notification = JSON.parse(e.data) as NotificationItem; + const notification = parseNotificationEvent(e.data); + if (!notification) return; notifications = [notification, ...notifications]; if (!notification.read) unreadCount += 1; }); @@ -317,6 +300,16 @@ onDestroy(() => { {/each} {/if} + +
+ + {m.notification_view_all()} + +
{/if} diff --git a/frontend/src/lib/utils/notifications.spec.ts b/frontend/src/lib/utils/notifications.spec.ts new file mode 100644 index 00000000..e1333d5a --- /dev/null +++ b/frontend/src/lib/utils/notifications.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { relativeTime, parseNotificationEvent } from '$lib/utils/notifications'; + +const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + +function msAgo(ms: number, now: Date): string { + return new Date(now.getTime() - ms).toISOString(); +} + +describe('relativeTime', () => { + const now = new Date('2024-06-15T12:00:00.000Z'); + + it('should use minute bucket for timestamps under 60 seconds ago', () => { + const ts = msAgo(30_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(0, 'minute')); + }); + + it('should use minute bucket for exactly 59 minutes ago', () => { + const ts = msAgo(59 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-59, 'minute')); + }); + + it('should use minute bucket for exactly 1 minute ago', () => { + const ts = msAgo(60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'minute')); + }); + + it('should use hour bucket for exactly 1 hour ago', () => { + const ts = msAgo(60 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'hour')); + }); + + it('should use hour bucket for 23 hours ago', () => { + const ts = msAgo(23 * 60 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-23, 'hour')); + }); + + it('should use day bucket for exactly 24 hours ago', () => { + const ts = msAgo(24 * 60 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-1, 'day')); + }); + + it('should use day bucket for 6 days ago', () => { + const ts = msAgo(6 * 24 * 60 * 60_000, now); + expect(relativeTime(ts, now)).toBe(rtf.format(-6, 'day')); + }); + + it('should default now to current time when omitted', () => { + // Just verify it returns a non-empty string — exact value depends on runtime clock + const ts = new Date(Date.now() - 5 * 60_000).toISOString(); + expect(relativeTime(ts)).toBeTruthy(); + }); +}); + +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'); + }); +}); diff --git a/frontend/src/lib/utils/notifications.ts b/frontend/src/lib/utils/notifications.ts new file mode 100644 index 00000000..a58f1b11 --- /dev/null +++ b/frontend/src/lib/utils/notifications.ts @@ -0,0 +1,42 @@ +export type NotificationItem = { + id: string; + type: 'REPLY' | 'MENTION'; + documentId: string; + referenceId: string; + annotationId: string | null; + read: boolean; + createdAt: string; + actorName: string; + documentTitle: string | null; +}; + +const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }); + +export function relativeTime(isoString: string, now: Date = new Date()): string { + const diffMs = now.getTime() - new Date(isoString).getTime(); + const diffMin = Math.floor(diffMs / 60_000); + if (diffMin < 1) return rtf.format(0, 'minute'); + if (diffMin < 60) return rtf.format(-diffMin, 'minute'); + const diffH = Math.floor(diffMin / 60); + if (diffH < 24) return rtf.format(-diffH, 'hour'); + const diffD = Math.floor(diffH / 24); + return rtf.format(-diffD, 'day'); +} + +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; + } +}