Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 0 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Routes>
<>
<NotificationDeepLinkHandler />
<Routes>
{/* Auth Routes - No Navbar/Footer */}
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage />} />
Expand Down
55 changes: 55 additions & 0 deletions src/lib/NotificationDeepLinkHandler.tsx
Original file line number Diff line number Diff line change
@@ -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<Notification>(`/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;
}
83 changes: 83 additions & 0 deletions src/lib/__tests__/NotificationDeepLinkHandler.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid="location">{`${location.pathname}${location.search}${location.hash}`}</div>;
}

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(
<MemoryRouter initialEntries={["/"]}>
<NotificationDeepLinkHandler />
<Routes>
<Route path="/adoption/:id/settlement" element={<CurrentLocation />} />
<Route path="/" element={<CurrentLocation />} />
</Routes>
</MemoryRouter>,
);

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(
<MemoryRouter initialEntries={["/"]}>
<NotificationDeepLinkHandler />
<Routes>
<Route path="/" element={<CurrentLocation />} />
<Route path="/notifications" element={<CurrentLocation />} />
</Routes>
</MemoryRouter>,
);

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();
});
});
110 changes: 110 additions & 0 deletions src/lib/hooks/__tests__/useNotificationCount.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<QueryClientProvider client={queryClient}>
<BrowserRouter>{children}</BrowserRouter>
</QueryClientProvider>
);
}

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 <div data-testid="notification-count">{count}</div>;
}

render(<NotificationCountTester />, {
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"));
});
});
4 changes: 3 additions & 1 deletion src/lib/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ export {
export {
useRealTimeStatusPolling,
type UseRealTimeStatusPollingOptions,
} from "./useRealTimeStatusPolling";
} from "./useRealTimeStatusPolling";

export { useNotificationCount } from "./useNotificationCount";
47 changes: 47 additions & 0 deletions src/lib/hooks/useNotificationCount.ts
Original file line number Diff line number Diff line change
@@ -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<NotificationCountResponse>(
["notification-count"],
() => apiClient.get<NotificationCountResponse>("/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<NotificationCountResponse>(["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,
};
}
Loading
Loading