Skip to content
Closed
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
156 changes: 156 additions & 0 deletions src/app/stats/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StatsPage />);

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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
expect(await screen.findByText("2")).toBeInTheDocument();

unmount();

expect(removeEventListenerSpy).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function)
);
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
112 changes: 90 additions & 22 deletions src/app/stats/page.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,116 @@
"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<Stats | null>(null);
const [error, setError] = useState<string | null>(null);
const [lastUpdated, setLastUpdated] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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<Stats>("/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<Stats>("/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 (
<main
id="main-content"
tabIndex={-1}
className="mx-auto flex min-h-[60vh] max-w-3xl flex-col gap-6 p-8 focus:outline-none"
>
<h1 className="text-3xl font-semibold tracking-tight">Stats</h1>
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Stats</h1>
{lastUpdated && (
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
Last updated <TimeAgo ts={lastUpdated} />
</p>
)}
</div>
<Button
type="button"
variant="secondary"
disabled={loading}
onClick={() => void refresh()}
>
{loading ? "Refreshing…" : "Refresh"}
</Button>
</header>
{error && <p role="alert" className="text-sm text-rose-600">{error}</p>}
{!stats && !error && <p>Loading…</p>}
{stats && (
<dl className="grid grid-cols-2 gap-4">
<div className="rounded-lg border border-neutral-200 p-4 text-center dark:border-neutral-800">
<dt className="text-xs uppercase text-neutral-500">Pairs</dt>
<dd className="mt-1 text-2xl font-semibold">{stats.totalPairs}</dd>
</div>
<div className="rounded-lg border border-neutral-200 p-4 text-center dark:border-neutral-800">
<dt className="text-xs uppercase text-neutral-500">Status</dt>
<dd className="mt-1 text-2xl font-semibold">
{stats.paused ? "Paused" : "Live"}
</dd>
</div>
<StatTile label="Pairs" value={stats.totalPairs} />
<StatTile label="Status" value={stats.paused ? "Paused" : "Live"} />
</dl>
)}
</main>
Expand Down