diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 0000000..b4570cc --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,45 @@ +# PR Summary + +Implements `useNotificationCount`, a new hook that polls unread notification count for the nav badge and resets the count when the notification centre is opened. + +## What changed + +- Added `src/lib/hooks/useNotificationCount.ts` + - polls `GET /notifications?status=UNREAD&limit=0` + - returns `{ count, isLoading }` + - pauses polling when the browser tab is hidden + - marks all notifications read and resets count to `0` when the route changes to `/notifications` + +- Updated `src/lib/hooks/index.ts` + - exported `useNotificationCount` + +- Updated `src/mocks/handlers/notify.ts` + - added mock support for `status=UNREAD&limit=0` count responses + +- Added unit tests in `src/lib/hooks/__tests__/useNotificationCount.test.tsx` + - validates unread count is returned + - validates polling pauses on hidden tab + - validates count resets to `0` when notification centre opens + +## Why this matters + +This hook powers the notification badge UX by keeping the unread count fresh and ensuring the badge clears when the user views the notification centre. It also avoids unnecessary polling when the app is not visible. + +## Testing + +Run: + +```bash +node_modules/.bin/vitest run src/lib/hooks/__tests__/useNotificationCount.test.tsx +``` + +Also validate polling hook integration: + +```bash +node_modules/.bin/vitest run src/lib/hooks/__tests__/usePolling.test.tsx src/lib/hooks/__tests__/useNotificationCount.test.tsx +``` + +## Notes + +- Uses the existing `usePolling` hook and `apiClient` infrastructure for consistency. +- The notification count hook is designed to be simple and reusable for any badge or nav component that needs unread notification state. diff --git a/package-lock.json b/package-lock.json index 1383bcf..cb07fdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3192,12 +3192,8 @@ }, "node_modules/csstype": { "version": "3.2.3", - - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" }, "node_modules/data-urls": { diff --git a/src/App.tsx b/src/App.tsx index 176107f..3c191b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,11 +20,14 @@ import ModalPreview from "./pages/ModalPreview"; import StatusPollingDemo from "./pages/StatusPollingDemo"; import CustodyTimelinePage from "./pages/CustodyTimelinePage"; import AdminApprovalQueuePage from "./pages/AdminApprovalQueuePage"; +import { NotificationDeepLinkHandler } from "./lib/NotificationDeepLinkHandler"; function App() { return ( - + <> + + {/* Auth Routes - No Navbar/Footer */} } /> } /> diff --git a/src/lib/NotificationDeepLinkHandler.tsx b/src/lib/NotificationDeepLinkHandler.tsx new file mode 100644 index 0000000..b13c43b --- /dev/null +++ b/src/lib/NotificationDeepLinkHandler.tsx @@ -0,0 +1,55 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { apiClient } from "./api-client"; +import { notificationRouter } from "./notificationRouter"; +import { NotFoundError } from "./api-errors"; +import type { Notification } from "../types/notifications"; + +export function NotificationDeepLinkHandler() { + const navigate = useNavigate(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const notificationId = params.get("notificationId")?.trim(); + + if (!notificationId) { + return; + } + + const removeNotificationId = () => { + params.delete("notificationId"); + const search = params.toString(); + const nextUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`; + window.history.replaceState(null, "", nextUrl); + }; + + const handleDeepLink = async () => { + let notification: Notification; + + try { + notification = await apiClient.get(`/notifications/${notificationId}`); + } catch (error) { + if (error instanceof NotFoundError) { + console.warn(`Notification deep-link not found: ${notificationId}`); + removeNotificationId(); + return; + } + + throw error; + } + + try { + await apiClient.patch(`/notifications/${notificationId}/read`); + } catch (error) { + console.warn(`Failed to mark notification ${notificationId} as read`, error); + } + + removeNotificationId(); + navigate(notificationRouter(notification), { replace: true }); + }; + + handleDeepLink(); + }, [navigate]); + + return null; +} diff --git a/src/lib/__tests__/NotificationDeepLinkHandler.test.tsx b/src/lib/__tests__/NotificationDeepLinkHandler.test.tsx new file mode 100644 index 0000000..3a6b9af --- /dev/null +++ b/src/lib/__tests__/NotificationDeepLinkHandler.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { NotificationDeepLinkHandler } from "../NotificationDeepLinkHandler"; +import { apiClient } from "../api-client"; +import { NotFoundError } from "../api-errors"; + +function CurrentLocation() { + const location = useLocation(); + return
{`${location.pathname}${location.search}${location.hash}`}
; +} + +describe("NotificationDeepLinkHandler", () => { + beforeEach(() => { + window.history.replaceState({}, "", "/"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reads notificationId, marks notification read, navigates, and removes the param", async () => { + window.history.replaceState({}, "", "/?notificationId=notif-001"); + + vi.spyOn(apiClient, "get").mockResolvedValue({ + id: "notif-001", + type: "ESCROW_FUNDED", + title: "Escrow Funded", + message: "Payment received", + time: "2026-03-24T10:00:00.000Z", + metadata: { resourceId: "adoption-001" }, + }); + vi.spyOn(apiClient, "patch").mockResolvedValue({}); + + const { getByTestId } = render( + + + + } /> + } /> + + , + ); + + await waitFor(() => { + expect(getByTestId("location").textContent).toBe("/adoption/adoption-001/settlement"); + }); + + expect(apiClient.get).toHaveBeenCalledWith("/notifications/notif-001"); + expect(apiClient.patch).toHaveBeenCalledWith("/notifications/notif-001/read"); + expect(window.location.search).toBe(""); + }); + + it("logs not found and removes the param without navigating", async () => { + window.history.replaceState({}, "", "/?notificationId=missing-id"); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(apiClient, "get").mockRejectedValue( + new NotFoundError("Not found", { status: 404 }), + ); + vi.spyOn(apiClient, "patch").mockResolvedValue({}); + + const { getByTestId } = render( + + + + } /> + } /> + + , + ); + + await waitFor(() => { + expect(getByTestId("location").textContent).toBe("/"); + }); + + expect(warnSpy).toHaveBeenCalledWith( + "Notification deep-link not found: missing-id", + ); + expect(window.location.search).toBe(""); + expect(apiClient.patch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/hooks/__tests__/useNotificationCount.test.tsx b/src/lib/hooks/__tests__/useNotificationCount.test.tsx new file mode 100644 index 0000000..f9c417a --- /dev/null +++ b/src/lib/hooks/__tests__/useNotificationCount.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, render, waitFor, act, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter, useNavigate } from "react-router-dom"; +import { useEffect, type ReactNode } from "react"; +import { useNotificationCount } from "../useNotificationCount"; +import { apiClient } from "../../api-client"; + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); +} + +function createWrapper(queryClient: QueryClient) { + return ({ children }: { children: ReactNode }) => ( + + {children} + + ); +} + +describe("useNotificationCount", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("returns the unread notification count and loading state", async () => { + const queryClient = createTestQueryClient(); + vi.spyOn(apiClient, "get").mockResolvedValue({ count: 7 }); + + const { result } = renderHook(() => useNotificationCount(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.count).toBe(7)); + expect(result.current.isLoading).toBe(false); + expect(apiClient.get).toHaveBeenCalledWith("/notifications?status=UNREAD&limit=0"); + }); + + it("pauses polling when document is hidden", async () => { + const queryClient = createTestQueryClient(); + + Object.defineProperty(document, "hidden", { + writable: true, + configurable: true, + value: false, + }); + + const fetchFn = vi.spyOn(apiClient, "get").mockResolvedValue({ count: 3 }); + + renderHook(() => useNotificationCount(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1)); + + vi.useFakeTimers(); + + await act(async () => { + Object.defineProperty(document, "hidden", { value: true }); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(120000); + }); + + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("resets unread count to 0 when the notification centre opens", async () => { + const queryClient = createTestQueryClient(); + vi.spyOn(apiClient, "get").mockResolvedValue({ count: 5 }); + const postSpy = vi.spyOn(apiClient, "post").mockResolvedValue({}); + + let navigateToNotifications: (() => void) | null = null; + + function NotificationCountTester() { + const { count } = useNotificationCount(); + const navigate = useNavigate(); + + useEffect(() => { + navigateToNotifications = () => navigate("/notifications"); + }, [navigate]); + + return
{count}
; + } + + render(, { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(screen.getByTestId("notification-count").textContent).toBe("5")); + + act(() => { + navigateToNotifications?.(); + }); + + await waitFor(() => expect(postSpy).toHaveBeenCalledWith("/notifications/read-all")); + await waitFor(() => expect(screen.getByTestId("notification-count").textContent).toBe("0")); + }); +}); diff --git a/src/lib/hooks/index.ts b/src/lib/hooks/index.ts index 91b3887..0640a72 100644 --- a/src/lib/hooks/index.ts +++ b/src/lib/hooks/index.ts @@ -23,4 +23,6 @@ export { export { useRealTimeStatusPolling, type UseRealTimeStatusPollingOptions, -} from "./useRealTimeStatusPolling"; \ No newline at end of file +} from "./useRealTimeStatusPolling"; + +export { useNotificationCount } from "./useNotificationCount"; \ No newline at end of file diff --git a/src/lib/hooks/useNotificationCount.ts b/src/lib/hooks/useNotificationCount.ts new file mode 100644 index 0000000..70545e4 --- /dev/null +++ b/src/lib/hooks/useNotificationCount.ts @@ -0,0 +1,47 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "../api-client"; +import { usePolling } from "./usePolling"; + +interface NotificationCountResponse { + count: number; +} + +export function useNotificationCount() { + const location = useLocation(); + const queryClient = useQueryClient(); + + const query = usePolling( + ["notification-count"], + () => apiClient.get("/notifications?status=UNREAD&limit=0"), + { + intervalMs: 60000, + pauseOnHidden: true, + }, + ); + + useEffect(() => { + if (location.pathname !== "/notifications") { + return; + } + + const markAllRead = async () => { + try { + await apiClient.post("/notifications/read-all"); + queryClient.setQueryData(["notification-count"], { + count: 0, + }); + } catch (error) { + console.warn("Failed to mark notifications read", error); + } + }; + + markAllRead(); + }, [location.pathname, queryClient]); + + return { + count: query.data?.count ?? 0, + isLoading: query.isLoading, + }; +} diff --git a/src/mocks/handlers/notify.ts b/src/mocks/handlers/notify.ts index 3b61fbf..97b6544 100644 --- a/src/mocks/handlers/notify.ts +++ b/src/mocks/handlers/notify.ts @@ -66,9 +66,31 @@ export const notifyHandlers = [ // GET /api/notifications — list all notifications for the current user http.get("/api/notifications", async ({ request }) => { await delay(getDelay(request)); + const url = new URL(request.url); + const status = url.searchParams.get("status"); + const limit = Number(url.searchParams.get("limit") ?? 0); + + if (status === "UNREAD" && limit === 0) { + return HttpResponse.json({ count: MOCK_NOTIFICATIONS.length }); + } + return HttpResponse.json(MOCK_NOTIFICATIONS); }), + // GET /api/notifications/:id — fetch an individual notification + http.get("/api/notifications/:id", async ({ params, request }) => { + await delay(getDelay(request)); + const notification = MOCK_NOTIFICATIONS.find( + (item) => item.id === (params.id as string), + ); + + if (!notification) { + return new HttpResponse(null, { status: 404 }); + } + + return HttpResponse.json(notification); + }), + // PATCH /api/notifications/:id/read — mark a single notification as read http.patch("/api/notifications/:id/read", async ({ request }) => { await delay(getDelay(request));