feat: notification center frontend core (PR-3)

- Bell icon in icon registry (Phosphor Bell)
- NotificationProvider context with polling (15s unread / 30s list)
- useNotifications() hook with optimistic updates
- NotificationCenter component: bell + badge + dropdown panel
- NotificationItem component: severity icon, title, relative time, actions
- AppShell integration: mount in header-actions, hidden on mobile
- CSS styles: dropdown, items, unread/read states, empty state
- formatRelativeTime utility (custom, no new deps)
- 25 frontend tests (9 hook + 6 item + 10 center)

Quality gates: vitest 25 passed, tsc clean, eslint clean
This commit is contained in:
2026-05-29 13:14:50 +02:00
parent cbd3436ff7
commit 2bec205a30
15 changed files with 1974 additions and 402 deletions
+286
View File
@@ -0,0 +1,286 @@
import React, {
createContext,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
getNotifications,
getUnreadCount,
markNotificationRead,
markAllNotificationsRead,
dismissNotification,
} from "../api/notifications";
import type { NotificationItem } from "../api/notifications";
export interface NotificationContextValue {
notifications: NotificationItem[];
unreadCount: number;
isLoading: boolean;
error: Error | null;
markRead: (id: string) => Promise<void>;
markAllRead: () => Promise<void>;
dismiss: (id: string) => Promise<void>;
refreshList: () => Promise<void>;
isDropdownOpen: boolean;
setIsDropdownOpen: (open: boolean) => void;
}
export const NotificationContext =
createContext<NotificationContextValue | null>(null);
const UNREAD_POLL_MS = 15000;
const LIST_POLL_MS = 30000;
export function NotificationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const stateRef = useRef({
notifications,
unreadCount,
isDropdownOpen,
stopped: false,
});
stateRef.current = {
notifications,
unreadCount,
isDropdownOpen,
stopped: false,
};
const unreadIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const listIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchUnreadCount = useCallback(async () => {
if (stateRef.current.stopped) return;
try {
const count = await getUnreadCount();
if (!stateRef.current.stopped) {
setUnreadCount(count);
}
} catch (err) {
const status = (err as { response?: { status?: number } })?.response
?.status;
if (status === 401) {
stateRef.current.stopped = true;
if (unreadIntervalRef.current) {
clearInterval(unreadIntervalRef.current);
unreadIntervalRef.current = null;
}
if (listIntervalRef.current) {
clearInterval(listIntervalRef.current);
listIntervalRef.current = null;
}
}
// Silently log other errors; next cycle proceeds
// eslint-disable-next-line no-console
console.error("Notification unread count poll failed", err);
}
}, []);
const fetchList = useCallback(async () => {
if (stateRef.current.stopped) return;
setIsLoading(true);
try {
const data = await getNotifications();
if (!stateRef.current.stopped) {
setNotifications(data.items);
}
} catch (err) {
const status = (err as { response?: { status?: number } })?.response
?.status;
if (status === 401) {
stateRef.current.stopped = true;
if (unreadIntervalRef.current) {
clearInterval(unreadIntervalRef.current);
unreadIntervalRef.current = null;
}
if (listIntervalRef.current) {
clearInterval(listIntervalRef.current);
listIntervalRef.current = null;
}
}
// eslint-disable-next-line no-console
console.error("Notification list poll failed", err);
} finally {
setIsLoading(false);
}
}, []);
const startPolling = useCallback(() => {
if (stateRef.current.stopped) return;
if (!unreadIntervalRef.current) {
void fetchUnreadCount();
unreadIntervalRef.current = setInterval(() => {
void fetchUnreadCount();
}, UNREAD_POLL_MS);
}
if (!listIntervalRef.current && !stateRef.current.isDropdownOpen) {
void fetchList();
listIntervalRef.current = setInterval(() => {
if (!stateRef.current.isDropdownOpen) {
void fetchList();
}
}, LIST_POLL_MS);
}
}, [fetchUnreadCount, fetchList]);
const stopPolling = useCallback(() => {
if (unreadIntervalRef.current) {
clearInterval(unreadIntervalRef.current);
unreadIntervalRef.current = null;
}
if (listIntervalRef.current) {
clearInterval(listIntervalRef.current);
listIntervalRef.current = null;
}
}, []);
// Handle visibility changes
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
stopPolling();
} else {
startPolling();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [startPolling, stopPolling]);
// Start/stop polling based on dropdown state
useEffect(() => {
if (isDropdownOpen) {
if (listIntervalRef.current) {
clearInterval(listIntervalRef.current);
listIntervalRef.current = null;
}
void fetchList();
} else {
if (
!listIntervalRef.current &&
!document.hidden &&
unreadIntervalRef.current
) {
listIntervalRef.current = setInterval(() => {
if (!stateRef.current.isDropdownOpen) {
void fetchList();
}
}, LIST_POLL_MS);
}
}
}, [isDropdownOpen, fetchList]);
// Initial start
useEffect(() => {
startPolling();
return () => {
stopPolling();
};
}, [startPolling, stopPolling]);
const markRead = useCallback(async (id: string) => {
const { notifications: currentNotifications, unreadCount: currentCount } =
stateRef.current;
const target = currentNotifications.find((n) => n.id === id);
const wasUnread = target ? target.read_at === null : false;
setNotifications(
currentNotifications.map((n) =>
n.id === id ? { ...n, read_at: new Date().toISOString() } : n,
),
);
if (wasUnread) {
setUnreadCount((c) => Math.max(0, c - 1));
}
setError(null);
try {
await markNotificationRead(id);
} catch (err) {
setNotifications(currentNotifications);
setUnreadCount(currentCount);
setError(err as Error);
}
}, []);
const markAllRead = useCallback(async () => {
const { notifications: currentNotifications, unreadCount: currentCount } =
stateRef.current;
setNotifications(
currentNotifications.map((n) =>
n.read_at === null ? { ...n, read_at: new Date().toISOString() } : n,
),
);
setUnreadCount(0);
setError(null);
try {
await markAllNotificationsRead();
} catch (err) {
setNotifications(currentNotifications);
setUnreadCount(currentCount);
setError(err as Error);
}
}, []);
const dismiss = useCallback(async (id: string) => {
const { notifications: currentNotifications, unreadCount: currentCount } =
stateRef.current;
const target = currentNotifications.find((n) => n.id === id);
const wasUnread = target ? target.read_at === null : false;
setNotifications(currentNotifications.filter((n) => n.id !== id));
if (wasUnread) {
setUnreadCount((c) => Math.max(0, c - 1));
}
setError(null);
try {
await dismissNotification(id);
} catch (err) {
setNotifications(currentNotifications);
setUnreadCount(currentCount);
setError(err as Error);
}
}, []);
const refreshList = useCallback(async () => {
await fetchList();
}, [fetchList]);
const value: NotificationContextValue = {
notifications,
unreadCount,
isLoading,
error,
markRead,
markAllRead,
dismiss,
refreshList,
isDropdownOpen,
setIsDropdownOpen,
};
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
}