From 7f943d1de66c28af455895cfe15ec6a1e09fd851 Mon Sep 17 00:00:00 2001 From: christopher Date: Sat, 28 Mar 2026 13:55:13 +0100 Subject: [PATCH 1/3] Add NotificationDeepLinkHandler and related tests for handling deep links --- package-lock.json | 4 - src/App.tsx | 5 +- src/lib/NotificationDeepLinkHandler.tsx | 55 ++++++++++++ .../NotificationDeepLinkHandler.test.tsx | 83 +++++++++++++++++++ src/mocks/handlers/notify.ts | 14 ++++ 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 src/lib/NotificationDeepLinkHandler.tsx create mode 100644 src/lib/__tests__/NotificationDeepLinkHandler.test.tsx 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/mocks/handlers/notify.ts b/src/mocks/handlers/notify.ts index 3b61fbf..46e2500 100644 --- a/src/mocks/handlers/notify.ts +++ b/src/mocks/handlers/notify.ts @@ -69,6 +69,20 @@ export const notifyHandlers = [ 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)); From acca36e008deaa67d580ef00692321c16365f2e3 Mon Sep 17 00:00:00 2001 From: christopher Date: Sat, 28 Mar 2026 14:10:19 +0100 Subject: [PATCH 2/3] Add useNotificationCount hook and related tests for managing notification counts --- .../__tests__/useNotificationCount.test.tsx | 110 ++++++++++++++++++ src/lib/hooks/index.ts | 4 +- src/lib/hooks/useNotificationCount.ts | 47 ++++++++ src/mocks/handlers/notify.ts | 8 ++ 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/lib/hooks/__tests__/useNotificationCount.test.tsx create mode 100644 src/lib/hooks/useNotificationCount.ts 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 46e2500..97b6544 100644 --- a/src/mocks/handlers/notify.ts +++ b/src/mocks/handlers/notify.ts @@ -66,6 +66,14 @@ 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); }), From f9686afc7171a8decffd4b26cb77cb16b17281ee Mon Sep 17 00:00:00 2001 From: christopher Date: Sat, 28 Mar 2026 14:19:11 +0100 Subject: [PATCH 3/3] Add useNotificationCount hook with polling and reset functionality for unread notifications --- PR_DESCRIPTION.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 PR_DESCRIPTION.md 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.