refactor(notifications): extract NotificationItem type and relativeTime to shared utility

Extracted from NotificationBell.svelte into $lib/utils/notifications.ts so the
history page can reuse them. relativeTime() now accepts an optional `now` param
for deterministic unit testing. Added parseNotificationEvent() for SSE payload
shape validation (NullX Finding 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-29 13:58:46 +02:00
committed by marcel
parent 03ee9ccec4
commit c8f7225506
3 changed files with 165 additions and 24 deletions

View File

@@ -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}
</ul>
{/if}
<div class="border-t border-line px-4 py-2">
<a
href="/notifications"
onclick={closeDropdown}
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
>
{m.notification_view_all()}
</a>
</div>
</div>
{/if}
</div>