diff --git a/README.md b/README.md index 3ab7b96..6b604fd 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Next.js app for [StableRoute](https://github.com/your-org/stableroute) — Stell - **Next.js 15** (App Router) with **React 19** - **TailwindCSS** for styling - Starter landing page; Stellar wallet integration can be added here +- Stats polling pauses in hidden tabs and supports manual refresh ## Prerequisites diff --git a/src/app/stats/page.test.tsx b/src/app/stats/page.test.tsx new file mode 100644 index 0000000..412a7fd --- /dev/null +++ b/src/app/stats/page.test.tsx @@ -0,0 +1,156 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import StatsPage from "./page"; + +type Stats = { totalPairs: number; paused: boolean }; + +function statsResponse(stats: Stats) { + return { + ok: true, + status: 200, + json: async () => stats, + } as unknown as Response; +} + +function setVisibility(value: DocumentVisibilityState) { + Object.defineProperty(document, "visibilityState", { + configurable: true, + value, + }); +} + +describe("StatsPage", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + jest.useFakeTimers(); + jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + setVisibility("visible"); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + jest.useRealTimers(); + jest.restoreAllMocks(); + delete (document as { visibilityState?: DocumentVisibilityState }).visibilityState; + }); + + it("renders stats with StatTile tiles and a last-updated timestamp", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce( + statsResponse({ totalPairs: 2, paused: false }) + ) as unknown as typeof globalThis.fetch; + + render(); + + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(screen.getByText("Live")).toBeInTheDocument(); + expect(screen.getByText(/Last updated/)).toHaveTextContent("just now"); + }); + + it("pauses interval polling while the tab is hidden", async () => { + const mockFetch = jest.fn().mockResolvedValueOnce( + statsResponse({ totalPairs: 2, paused: false }) + ); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + render(); + expect(await screen.findByText("2")).toBeInTheDocument(); + + setVisibility("hidden"); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("resumes with an immediate fetch when the tab becomes visible", async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce(statsResponse({ totalPairs: 2, paused: false })) + .mockResolvedValueOnce(statsResponse({ totalPairs: 5, paused: true })); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + render(); + expect(await screen.findByText("2")).toBeInTheDocument(); + + setVisibility("hidden"); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + setVisibility("visible"); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(await screen.findByText("5")).toBeInTheDocument(); + expect(screen.getByText("Paused")).toBeInTheDocument(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("keeps the last good stats and stops polling after an error", async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce(statsResponse({ totalPairs: 2, paused: false })) + .mockRejectedValueOnce(new Error("stats unavailable")); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + render(); + expect(await screen.findByText("2")).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(await screen.findByRole("alert")).toHaveTextContent("stats unavailable"); + expect(screen.getByText("2")).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("refreshes manually and resumes polling after a successful refresh", async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce(statsResponse({ totalPairs: 2, paused: false })) + .mockResolvedValueOnce(statsResponse({ totalPairs: 6, paused: false })) + .mockResolvedValueOnce(statsResponse({ totalPairs: 7, paused: false })); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + render(); + expect(await screen.findByText("2")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Refresh/i })); + expect(await screen.findByText("6")).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(await screen.findByText("7")).toBeInTheDocument(); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it("cleans up listeners and intervals on unmount", async () => { + const removeEventListenerSpy = jest.spyOn(document, "removeEventListener"); + const clearIntervalSpy = jest.spyOn(globalThis, "clearInterval"); + globalThis.fetch = jest.fn().mockResolvedValueOnce( + statsResponse({ totalPairs: 2, paused: false }) + ) as unknown as typeof globalThis.fetch; + + const { unmount } = render(); + expect(await screen.findByText("2")).toBeInTheDocument(); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function) + ); + expect(clearIntervalSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx index a632ccb..ab225c3 100644 --- a/src/app/stats/page.tsx +++ b/src/app/stats/page.tsx @@ -1,27 +1,85 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/Button"; +import { StatTile } from "@/components/StatTile"; +import { TimeAgo } from "@/components/TimeAgo"; import { apiGet } from "@/lib/apiClient"; type Stats = { totalPairs: number; paused: boolean }; +const POLL_INTERVAL_MS = 5000; + export default function StatsPage() { const [stats, setStats] = useState(null); const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + const [loading, setLoading] = useState(false); + const intervalRef = useRef | null>(null); + const mountedRef = useRef(false); + const stoppedByErrorRef = useRef(false); + + const stopPolling = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const loadStats = useCallback(async () => { + setLoading(true); + try { + const nextStats = await apiGet("/api/v1/stats"); + if (!mountedRef.current) return false; + setStats(nextStats); + setLastUpdated(Date.now()); + setError(null); + stoppedByErrorRef.current = false; + return true; + } catch (e) { + if (!mountedRef.current) return false; + setError((e as Error).message); + stoppedByErrorRef.current = true; + stopPolling(); + return false; + } finally { + if (mountedRef.current) setLoading(false); + } + }, [stopPolling]); + + /** Starts polling only while the tab is visible and the last request succeeded. */ + const startPolling = useCallback(() => { + stopPolling(); + if (document.visibilityState === "hidden" || stoppedByErrorRef.current) return; + intervalRef.current = setInterval(() => { + void loadStats(); + }, POLL_INTERVAL_MS); + }, [loadStats, stopPolling]); + + const refresh = useCallback(async () => { + const ok = await loadStats(); + if (ok) startPolling(); + }, [loadStats, startPolling]); useEffect(() => { - let cancelled = false; - const tick = () => - apiGet("/api/v1/stats") - .then((s) => !cancelled && setStats(s)) - .catch((e) => !cancelled && setError(e.message)); - tick(); - const t = setInterval(tick, 5000); + mountedRef.current = true; + void refresh(); + + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") { + stopPolling(); + return; + } + void refresh(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => { - cancelled = true; - clearInterval(t); + mountedRef.current = false; + stopPolling(); + document.removeEventListener("visibilitychange", onVisibilityChange); }; - }, []); + }, [refresh, stopPolling]); return (
-

Stats

+
+
+

Stats

+ {lastUpdated && ( +

+ Last updated +

+ )} +
+ +
{error &&

{error}

} + {!stats && !error &&

Loading…

} {stats && (
-
-
Pairs
-
{stats.totalPairs}
-
-
-
Status
-
- {stats.paused ? "Paused" : "Live"} -
-
+ +
)}