diff --git a/app/frontend/src/__tests__/notifications.test.tsx b/app/frontend/src/__tests__/notifications.test.tsx new file mode 100644 index 000000000..b40084a5c --- /dev/null +++ b/app/frontend/src/__tests__/notifications.test.tsx @@ -0,0 +1,321 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act, waitFor } from "@testing-library/react"; +import React from "react"; +import { + sortNotifications, + filterNotifications, + formatRelativeTime, + INITIAL_NOTIFICATIONS, + NOTIFICATION_STORAGE_KEY, + type StoredNotification, +} from "@/lib/notifications"; +import { + NotificationCenterProvider, + useNotificationCenter, +} from "@/components/NotificationCenterProvider"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const unread: StoredNotification = { + id: "a", + category: "payments", + title: "Unread", + description: "desc", + href: "/", + actionLabel: "Go", + createdAt: "2026-04-23T09:00:00.000Z", + readAt: null, +}; + +const read: StoredNotification = { + id: "b", + category: "escrows", + title: "Read", + description: "desc", + href: "/", + actionLabel: "Go", + createdAt: "2026-04-23T10:00:00.000Z", + readAt: "2026-04-23T10:05:00.000Z", +}; + +const older: StoredNotification = { + id: "c", + category: "system", + title: "Older unread", + description: "desc", + href: "/", + actionLabel: "Go", + createdAt: "2026-04-22T08:00:00.000Z", + readAt: null, +}; + +// --------------------------------------------------------------------------- +// sortNotifications +// --------------------------------------------------------------------------- + +describe("sortNotifications", () => { + it("places unread notifications before read ones", () => { + const result = sortNotifications([read, unread]); + expect(result[0].id).toBe("a"); // unread first + expect(result[1].id).toBe("b"); // read second + }); + + it("within unread items, sorts newest first", () => { + const newerUnread: StoredNotification = { + ...unread, + id: "newer", + createdAt: "2026-04-24T09:00:00.000Z", + }; + const result = sortNotifications([older, newerUnread]); + expect(result[0].id).toBe("newer"); + expect(result[1].id).toBe("c"); + }); + + it("does not mutate the input array", () => { + const input = [read, unread]; + const original = [...input]; + sortNotifications(input); + expect(input).toEqual(original); + }); + + it("handles an empty array", () => { + expect(sortNotifications([])).toEqual([]); + }); + + it("handles all-read items sorted newest first", () => { + const old: StoredNotification = { ...read, id: "old", createdAt: "2026-01-01T00:00:00.000Z" }; + const recent: StoredNotification = { ...read, id: "recent", createdAt: "2026-06-01T00:00:00.000Z" }; + const result = sortNotifications([old, recent]); + expect(result[0].id).toBe("recent"); + }); +}); + +// --------------------------------------------------------------------------- +// filterNotifications +// --------------------------------------------------------------------------- + +describe("filterNotifications", () => { + const items = [unread, read, older]; + + it("returns all notifications when category=all and readState=all", () => { + expect(filterNotifications(items, "all", "all")).toHaveLength(3); + }); + + it("filters by category", () => { + const result = filterNotifications(items, "payments", "all"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("a"); + }); + + it("filters unread only", () => { + const result = filterNotifications(items, "all", "unread"); + expect(result.every((n) => n.readAt === null)).toBe(true); + expect(result).toHaveLength(2); // unread + older + }); + + it("filters read only", () => { + const result = filterNotifications(items, "all", "read"); + expect(result.every((n) => Boolean(n.readAt))).toBe(true); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("b"); + }); + + it("combines category and readState filters", () => { + const result = filterNotifications(items, "escrows", "read"); + expect(result).toHaveLength(1); + expect(result[0].id).toBe("b"); + }); + + it("returns empty array when no items match", () => { + expect(filterNotifications(items, "system", "read")).toHaveLength(0); + }); + + it("does not mutate the input array", () => { + const input = [unread, read]; + const original = [...input]; + filterNotifications(input, "all", "unread"); + expect(input).toEqual(original); + }); +}); + +// --------------------------------------------------------------------------- +// formatRelativeTime +// --------------------------------------------------------------------------- + +describe("formatRelativeTime", () => { + beforeEach(() => { + // Pin Date.now() to 2026-04-23T12:00:00.000Z for deterministic tests. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-23T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns a relative time string for minutes", () => { + const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString(); + const result = formatRelativeTime(ts); + expect(result).toMatch(/10 minutes ago/i); + }); + + it("returns a relative time string for hours", () => { + const ts = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + const result = formatRelativeTime(ts); + expect(result).toMatch(/3 hours ago/i); + }); + + it("returns a relative time string for days", () => { + const ts = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(); + const result = formatRelativeTime(ts); + expect(result).toMatch(/2 days ago/i); + }); + + it("returns a string (never throws) for a very old timestamp", () => { + const result = formatRelativeTime("2020-01-01T00:00:00.000Z"); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + }); + + it("returns a future-tense string for a future timestamp", () => { + const ts = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + const result = formatRelativeTime(ts); + expect(result).toMatch(/in 2 hours/i); + }); +}); + +// --------------------------------------------------------------------------- +// NotificationCenterProvider — SSR-safe hydration +// --------------------------------------------------------------------------- + +/** Consumer that just exposes context values as data-* attributes for assertion. */ +function ContextProbe() { + const { unreadCount, hasHydrated, notifications } = useNotificationCenter(); + return ( +
+ ); +} + +describe("NotificationCenterProvider — SSR hydration", () => { + const storageKey = NOTIFICATION_STORAGE_KEY; + + beforeEach(() => { + localStorage.clear(); + }); + + it("starts with hasHydrated=false on initial render", () => { + render( + + + , + ); + // The probe renders synchronously; hasHydrated is set in a useEffect so it + // should still be false at this point unless React batches the effect. + // After the initial render we check via waitFor. + const probe = screen.getByTestId("probe"); + // By the time the DOM is painted the effect may have already run in jsdom, + // so we assert the final stable state: hasHydrated must become true. + return waitFor(() => { + expect(probe.dataset.hydrated).toBe("true"); + }); + }); + + it("loads INITIAL_NOTIFICATIONS when localStorage is empty", async () => { + render( + + + , + ); + const probe = screen.getByTestId("probe"); + await waitFor(() => { + expect(probe.dataset.hydrated).toBe("true"); + }); + expect(Number(probe.dataset.count)).toBe(INITIAL_NOTIFICATIONS.length); + }); + + it("merges stored readAt values from localStorage on hydration", async () => { + // Pre-seed localStorage with one notification marked as read. + const stored: StoredNotification[] = INITIAL_NOTIFICATIONS.map((n) => + n.id === "payment-milestone" ? { ...n, readAt: "2026-04-23T10:00:00.000Z" } : n, + ); + localStorage.setItem(storageKey, JSON.stringify(stored)); + + render( + + + , + ); + const probe = screen.getByTestId("probe"); + await waitFor(() => { + expect(probe.dataset.hydrated).toBe("true"); + }); + // One extra notification is now read; unreadCount should be one less. + const expectedUnread = INITIAL_NOTIFICATIONS.filter((n) => n.readAt === null).length - 1; + expect(Number(probe.dataset.unread)).toBe(expectedUnread); + }); + + it("gracefully handles corrupt localStorage data without throwing", async () => { + localStorage.setItem(storageKey, "not-valid-json{{{"); + // Should not throw; falls back to INITIAL_NOTIFICATIONS. + render( + + + , + ); + const probe = screen.getByTestId("probe"); + await waitFor(() => { + expect(probe.dataset.hydrated).toBe("true"); + }); + expect(Number(probe.dataset.count)).toBe(INITIAL_NOTIFICATIONS.length); + }); + + it("persists notifications to localStorage after hydration", async () => { + function MarkReader() { + const { markAsRead } = useNotificationCenter(); + return ( + + ); + } + + render( + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("probe").dataset.hydrated).toBe("true"); + }); + + act(() => { + screen.getByTestId("mark").click(); + }); + + await waitFor(() => { + const stored = localStorage.getItem(storageKey); + expect(stored).not.toBeNull(); + const parsed: StoredNotification[] = JSON.parse(stored!); + const target = parsed.find((n) => n.id === "payment-milestone"); + expect(target?.readAt).not.toBeNull(); + }); + }); + + it("throws if useNotificationCenter is called outside provider", () => { + // Suppress React's error boundary noise in the test output. + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow( + "useNotificationCenter must be used inside NotificationCenterProvider.", + ); + spy.mockRestore(); + }); +}); diff --git a/app/frontend/src/components/NotificationBell.tsx b/app/frontend/src/components/NotificationBell.tsx index 70a4ad43e..e090d7aeb 100644 --- a/app/frontend/src/components/NotificationBell.tsx +++ b/app/frontend/src/components/NotificationBell.tsx @@ -12,7 +12,7 @@ import { useNotificationCenter } from "@/components/NotificationCenterProvider"; export function NotificationBell() { const pathname = usePathname(); - const { notifications, unreadCount, markAsRead, markAllAsRead } = + const { notifications, unreadCount, markAsRead, markAllAsRead, hasHydrated } = useNotificationCenter(); const [isOpen, setIsOpen] = useState(false); const [readState, setReadState] = @@ -57,6 +57,11 @@ export function NotificationBell() { [notifications, readState], ); + // Only show the badge once the client has hydrated from localStorage so the + // server-rendered HTML (always 0 for an anonymous visitor) matches the initial + // client render before React takes over. After hydration the real count is shown. + const displayCount = hasHydrated ? unreadCount : 0; + return (
@@ -139,7 +144,7 @@ export function NotificationBell() {
- {unreadCount} unread + {displayCount} unread void; markAllAsRead: () => void; + /** True once localStorage has been read on the client. Use this to suppress + * hydration mismatches in any component that renders unread-count badges. */ + hasHydrated: boolean; }; const NotificationCenterContext = @@ -56,9 +59,16 @@ export function NotificationCenterProvider({ const [notifications, setNotifications] = useState( sortNotifications(INITIAL_NOTIFICATIONS), ); + // hasHydrated starts false on both server and first client render so the + // initial HTML matches. It is flipped to true in a useEffect (client-only), + // after which localStorage has been read and badge counts are accurate. const [hasHydrated, setHasHydrated] = useState(false); + // Read persisted state from localStorage — client only. useEffect(() => { + const isClient = typeof window !== "undefined"; + if (!isClient) return; + try { const storedValue = window.localStorage.getItem(NOTIFICATION_STORAGE_KEY); @@ -73,15 +83,20 @@ export function NotificationCenterProvider({ } }, []); + // Persist to localStorage whenever notifications change after hydration. useEffect(() => { if (!hasHydrated) { return; } - window.localStorage.setItem( - NOTIFICATION_STORAGE_KEY, - JSON.stringify(notifications), - ); + try { + window.localStorage.setItem( + NOTIFICATION_STORAGE_KEY, + JSON.stringify(notifications), + ); + } catch (error) { + console.error("Unable to persist notifications", error); + } }, [hasHydrated, notifications]); const unreadCount = useMemo( @@ -95,6 +110,7 @@ export function NotificationCenterProvider({ () => ({ notifications, unreadCount, + hasHydrated, markAsRead: (id: string) => { setNotifications((currentNotifications) => sortNotifications( @@ -124,7 +140,7 @@ export function NotificationCenterProvider({ ); }, }), - [notifications, unreadCount], + [notifications, unreadCount, hasHydrated], ); return ( diff --git a/app/frontend/src/components/NotificationFeed.tsx b/app/frontend/src/components/NotificationFeed.tsx index c622db75c..abbad1732 100644 --- a/app/frontend/src/components/NotificationFeed.tsx +++ b/app/frontend/src/components/NotificationFeed.tsx @@ -67,7 +67,16 @@ export function NotificationFeed({ Unread ) : null} - + {/* + * suppressHydrationWarning: formatRelativeTime calls Date.now() so + * the server-rendered string and the first client render may differ + * by a second or two. This is cosmetic — suppressing the warning + * is the correct approach here rather than deferring the render. + */} + {formatRelativeTime(notification.createdAt)}
diff --git a/app/frontend/src/lib/notifications.ts b/app/frontend/src/lib/notifications.ts index 8abdcdadb..0cc6b1f31 100644 --- a/app/frontend/src/lib/notifications.ts +++ b/app/frontend/src/lib/notifications.ts @@ -95,6 +95,10 @@ export const INITIAL_NOTIFICATIONS: StoredNotification[] = [ }, ]; +/** + * Pure sort — no browser globals. Safe to call on the server or client. + * Unread notifications appear first; within each group they are sorted newest first. + */ export function sortNotifications( notifications: StoredNotification[], ): StoredNotification[] { @@ -111,6 +115,9 @@ export function sortNotifications( }); } +/** + * Pure filter — no browser globals. Safe to call on the server or client. + */ export function filterNotifications( notifications: StoredNotification[], category: NotificationCategory | "all", @@ -129,20 +136,41 @@ export function filterNotifications( }); } +/** + * Returns a human-readable relative time string such as "3 hours ago". + * + * SSR safety: `Intl.RelativeTimeFormat` and `Date.parse` are available in + * Node.js, so this function never throws on the server. However the string + * it produces depends on `Date.now()`, which means the server render and the + * first client render may produce different strings — a hydration mismatch. + * + * Components that call this function should either: + * a) render it only after the client has hydrated (wrap in a `useEffect` or + * guard with `hasHydrated` from `useNotificationCenter`), or + * b) suppress the hydration warning with `suppressHydrationWarning` on the + * wrapping element when an exact match is not critical. + */ export function formatRelativeTime(isoTimestamp: string): string { const differenceMs = Date.parse(isoTimestamp) - Date.now(); const differenceMinutes = Math.round(differenceMs / 60000); - const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); - if (Math.abs(differenceMinutes) < 60) { - return formatter.format(differenceMinutes, "minute"); - } + try { + const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); - const differenceHours = Math.round(differenceMinutes / 60); - if (Math.abs(differenceHours) < 24) { - return formatter.format(differenceHours, "hour"); - } + if (Math.abs(differenceMinutes) < 60) { + return formatter.format(differenceMinutes, "minute"); + } - const differenceDays = Math.round(differenceHours / 24); - return formatter.format(differenceDays, "day"); + const differenceHours = Math.round(differenceMinutes / 60); + if (Math.abs(differenceHours) < 24) { + return formatter.format(differenceHours, "hour"); + } + + const differenceDays = Math.round(differenceHours / 24); + return formatter.format(differenceDays, "day"); + } catch { + // Fallback for environments where Intl.RelativeTimeFormat is unavailable. + const absDays = Math.abs(Math.round(differenceMs / 86400000)); + return absDays === 0 ? "today" : `${absDays} day${absDays !== 1 ? "s" : ""} ago`; + } } diff --git a/app/frontend/vitest.config.ts b/app/frontend/vitest.config.ts index 01cfd8e1a..170ab12a8 100644 --- a/app/frontend/vitest.config.ts +++ b/app/frontend/vitest.config.ts @@ -5,6 +5,12 @@ export default defineConfig({ test: { environment: "jsdom", globals: true, + setupFiles: ["./vitest.setup.ts"], + include: [ + "src/**/*.test.{ts,tsx}", + "src/**/__tests__/**/*.{ts,tsx}", + "__tests__/**/*.{ts,tsx}", + ], setupFiles: [], }, esbuild: { diff --git a/app/frontend/vitest.setup.ts b/app/frontend/vitest.setup.ts new file mode 100644 index 000000000..d0de870dc --- /dev/null +++ b/app/frontend/vitest.setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom";