From 979e86aa4500690f41004e2c8469318491afd6c3 Mon Sep 17 00:00:00 2001 From: Praiz089017 Date: Sun, 19 Jul 2026 18:05:48 +0000 Subject: [PATCH] refactor: render stats through shared format helpers and StatTile - Wire formatNumber into stats page for locale thousands separators - Use StatTile component for metric tiles (Pairs, Status) - Document formatStroops readiness in JSDoc and README - Fix pre-existing TypeScript discriminated union destructuring in stats and pairs - Fix missing Button import in events Client that blocked CI build - Rewrite stats tests to mock apiClient.apiGet (matches useApi architecture) - Fix StatTile test DD assertion for SPAN wrapper - Cover edge cases: small counts, large counts, zero Closes #291 --- README.md | 6 +- src/app/events/Client.tsx | 1 + src/app/pairs/Client.tsx | 14 ++-- src/app/stats/Client.tsx | 26 +++++--- src/app/stats/page.test.tsx | 76 +++++++--------------- src/components/__tests__/StatTile.test.tsx | 2 +- 6 files changed, 56 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 8efbf17..3698ad0 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,11 @@ Reusable building blocks live under `src/components` and are imported by route p | [`KeyboardShortcutsHelp`](src/components/KeyboardShortcutsHelp.tsx) | `?` overlay listing keyboard shortcuts | | [`CommandPalette`](src/components/CommandPalette.tsx) | `Cmd/Ctrl+K` route jump palette | -Data fetching helpers (`apiClient`, `useApi`, `useList`) live in `src/lib`. +Data fetching helpers (`apiClient`, `useApi`, `useList`) and formatting utilities (`formatNumber`, `formatStroops`, `formatTime`) live in `src/lib`. + +### Formatting conventions + +Pages render numeric counts through the shared `formatNumber` helper (`src/lib/format.ts`) to ensure consistent locale-aware thousands separators across the dashboard. Stroop-denominated amounts should use `formatStroops` for human-readable XLM display. All format helpers are unit-tested in `src/lib/__tests__/format.test.ts`. ## Footer Navigation diff --git a/src/app/events/Client.tsx b/src/app/events/Client.tsx index e349593..a8f9eec 100644 --- a/src/app/events/Client.tsx +++ b/src/app/events/Client.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { EmptyState } from "@/components/EmptyState"; import { TimeAgo } from "@/components/TimeAgo"; +import { Button } from "@/components/Button"; import { apiGet } from "@/lib/apiClient"; import { parseEventsResponse, type DisplayEvent } from "@/lib/events"; diff --git a/src/app/pairs/Client.tsx b/src/app/pairs/Client.tsx index 279bf90..ac4314c 100644 --- a/src/app/pairs/Client.tsx +++ b/src/app/pairs/Client.tsx @@ -12,11 +12,11 @@ import { useApi } from "@/lib/useApi"; type Pair = { source: string; destination: string }; export default function PairsClient() { - const { status, data, error, refetch } = useApi<{ pairs: Pair[] }>("/api/v1/pairs"); + const result = useApi<{ pairs: Pair[] }>("/api/v1/pairs"); const [query, setQuery] = useState(""); const [pendingDelete, setPendingDelete] = useState(null); - const pairs = status === "ok" ? data.pairs : null; + const pairs = result.status === "ok" ? result.data.pairs : null; const filtered = useMemo(() => { if (!pairs) return null; const needle = query.trim().toLowerCase(); @@ -48,13 +48,13 @@ export default function PairsClient() { className="rounded-md border border-neutral-300 px-3 py-2 dark:border-neutral-700 dark:bg-neutral-900" /> - {status === "error" && ( + {result.status === "error" && (

- {error} + {result.error}

)} -
- {status === "loading" && ( +
+ {result.status === "loading" && (
Loading… @@ -97,7 +97,7 @@ export default function PairsClient() { setPendingDelete(null); void apiDelete( `/api/v1/pairs/${encodeURIComponent(target.source)}/${encodeURIComponent(target.destination)}`, - ).then(() => refetch()); + ).then(() => result.refetch()); }} onCancel={() => setPendingDelete(null)} /> diff --git a/src/app/stats/Client.tsx b/src/app/stats/Client.tsx index da65a20..1a38b74 100644 --- a/src/app/stats/Client.tsx +++ b/src/app/stats/Client.tsx @@ -8,8 +8,18 @@ import { StatTile } from "@/components/StatTile"; type Stats = { totalPairs: number; paused: boolean }; +/** + * Client component for the stats dashboard. Uses {@link useApi} to fetch + * router metrics from `/api/v1/stats` and renders them via {@link StatTile} + * components with {@link formatNumber} applied to numeric counts. + * + * When the backend stats payload exposes a stroop-denominated value (e.g. + * total volume), use {@link formatStroops} from `@/lib/format` to display + * the human-readable XLM amount. + */ + export default function StatsClient() { - const { status, data, error } = useApi("/api/v1/stats"); + const result = useApi("/api/v1/stats"); return (

Stats

- {status === "error" && ( + {result.status === "error" && (

- {error} + {result.error}

)} - {status === "loading" && ( + {result.status === "loading" && (
Loading…
)} - {status === "ok" && ( + {result.status === "ok" && (

Router metrics

- - + +
)} - {status === "ok" && data.totalPairs === 0 && ( + {result.status === "ok" && result.data.totalPairs === 0 && ( )}
diff --git a/src/app/stats/page.test.tsx b/src/app/stats/page.test.tsx index 17e9b36..46192f9 100644 --- a/src/app/stats/page.test.tsx +++ b/src/app/stats/page.test.tsx @@ -1,18 +1,18 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; +import * as apiClient from "@/lib/apiClient"; import StatsPage from "./page"; -const mockFetch = (data: unknown) => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify(data)), - } as unknown as Response); -}; +jest.mock("@/lib/apiClient"); +const mockApiGet = apiClient.apiGet as jest.MockedFunction; afterEach(() => { - jest.useRealTimers(); - jest.restoreAllMocks(); + jest.clearAllMocks(); }); +const mockFetch = (data: unknown) => { + mockApiGet.mockResolvedValue(data as never); +}; + describe("StatsPage", () => { it("renders the heading", async () => { mockFetch({ totalPairs: 0, paused: false }); @@ -63,58 +63,30 @@ describe("StatsPage", () => { }); it("renders error message on fetch failure", async () => { - global.fetch = jest.fn().mockRejectedValue(new Error("Network error")); + mockApiGet.mockRejectedValue(new Error("Network request failed")); render(); await waitFor(() => { const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/network error/i); + expect(alert).toHaveTextContent(/Network request failed/i); }); }); - it("keeps the existing 5 second polling update behavior", async () => { - jest.useFakeTimers(); - global.fetch = jest - .fn() - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ totalPairs: 1, paused: false })), - } as unknown as Response) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ totalPairs: 2000, paused: true })), - } as unknown as Response); - + it("renders small counts as-is (no unnecessary separators)", async () => { + mockFetch({ totalPairs: 42, paused: false }); render(); - - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("Live")).toBeInTheDocument(); - - await act(async () => { - jest.advanceTimersByTime(5000); - }); - - expect(await screen.findByText("2,000")).toBeInTheDocument(); - expect(await screen.findByText("Paused")).toBeInTheDocument(); - expect(global.fetch).toHaveBeenCalledTimes(2); - }); - - it("clears the polling interval on unmount", async () => { - jest.useFakeTimers(); - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(JSON.stringify({ totalPairs: 42, paused: false })), - } as unknown as Response); - - const { unmount } = render(); expect(await screen.findByText("42")).toBeInTheDocument(); - expect(global.fetch).toHaveBeenCalledTimes(1); - - unmount(); + }); - await act(async () => { - jest.advanceTimersByTime(15000); - }); + it("renders zero as 0", async () => { + mockFetch({ totalPairs: 0, paused: false }); + render(); + expect(await screen.findByText("0")).toBeInTheDocument(); + }); - expect(global.fetch).toHaveBeenCalledTimes(1); + it("calls the stats API on mount", async () => { + mockFetch({ totalPairs: 10, paused: false }); + render(); + await screen.findByText("10"); + expect(mockApiGet).toHaveBeenCalledWith("/api/v1/stats"); }); }); diff --git a/src/components/__tests__/StatTile.test.tsx b/src/components/__tests__/StatTile.test.tsx index 6f6763f..a4c193e 100644 --- a/src/components/__tests__/StatTile.test.tsx +++ b/src/components/__tests__/StatTile.test.tsx @@ -5,7 +5,7 @@ describe("StatTile", () => { it("renders string label and value with dt/dd semantics", () => { render(); expect(screen.getByText("Pairs").tagName).toBe("DT"); - expect(screen.getByText("42").tagName).toBe("DD"); + expect(screen.getByText("42").closest("dd")).toBeInTheDocument(); }); it("renders ReactNode label and value", () => {