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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/app/events/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
14 changes: 7 additions & 7 deletions src/app/pairs/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pair | null>(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();
Expand Down Expand Up @@ -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"
/>
</label>
{status === "error" && (
{result.status === "error" && (
<p role="alert" className="text-sm text-rose-600">
{error}
{result.error}
</p>
)}
<section aria-live="polite" aria-busy={status === "loading"} className="contents">
{status === "loading" && (
<section aria-live="polite" aria-busy={result.status === "loading"} className="contents">
{result.status === "loading" && (
<div className="flex items-center gap-2 text-sm text-neutral-600">
<Spinner label="Loading pairs" />
Loading…
Expand Down Expand Up @@ -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)}
/>
Expand Down
26 changes: 18 additions & 8 deletions src/app/stats/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Stats>("/api/v1/stats");
const result = useApi<Stats>("/api/v1/stats");

return (
<main
Expand All @@ -18,29 +28,29 @@ export default function StatsClient() {
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>
{status === "error" && (
{result.status === "error" && (
<p role="alert" className="text-sm text-rose-600">
{error}
{result.error}
</p>
)}
{status === "loading" && (
{result.status === "loading" && (
<div className="flex items-center gap-2 text-sm">
<Spinner label="Loading stats" />
Loading…
</div>
)}
{status === "ok" && (
{result.status === "ok" && (
<section aria-labelledby="stats-metrics-heading">
<h2 id="stats-metrics-heading" className="sr-only">
Router metrics
</h2>
<dl className="grid grid-cols-2 gap-4">
<StatTile label="Pairs" value={formatNumber(data.totalPairs)} />
<StatTile label="Status" value={data.paused ? "Paused" : "Live"} />
<StatTile label="Pairs" value={formatNumber(result.data.totalPairs)} />
<StatTile label="Status" value={result.data.paused ? "Paused" : "Live"} />
</dl>
</section>
)}
{status === "ok" && data.totalPairs === 0 && (
{result.status === "ok" && result.data.totalPairs === 0 && (
<EmptyState title="No pairs yet" description="Register a pair to see metrics." />
)}
</main>
Expand Down
76 changes: 24 additions & 52 deletions src/app/stats/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof apiClient.apiGet>;

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 });
Expand Down Expand Up @@ -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(<StatsPage />);
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(<StatsPage />);

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(<StatsPage />);
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(<StatsPage />);
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(<StatsPage />);
await screen.findByText("10");
expect(mockApiGet).toHaveBeenCalledWith("/api/v1/stats");
});
});
2 changes: 1 addition & 1 deletion src/components/__tests__/StatTile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ describe("StatTile", () => {
it("renders string label and value with dt/dd semantics", () => {
render(<StatTile label="Pairs" value="42" />);
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", () => {
Expand Down