Skip to content
Merged
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
109 changes: 68 additions & 41 deletions src/app/stats/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,50 +177,77 @@ export default function StatsClient() {
>
<h1 className="text-3xl font-semibold tracking-tight">Stats</h1>
{status === 'error' && (
<p role="alert" className="text-sm text-rose-600">
{error}
</p>
)}
{status === 'loading' && (
<div className="flex items-center gap-2 text-sm">
<Spinner label="Loading stats" />
Loading…
</div>
)}
{status === 'success' && data && (
<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'} />
</dl>
{lastUpdatedAt !== null && <LastUpdated timestamp={lastUpdatedAt} />}
<div className="mt-4 flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => downloadStatsSnapshot(data, 'json')}
>
Download JSON
</Button>
<Button
type="button"
variant="secondary"
onClick={() => downloadStatsSnapshot(data, 'csv')}
>
Download CSV
</Button>
<section className="rounded-lg border border-rose-200 bg-rose-50 p-6 text-center dark:border-rose-900 dark:bg-rose-950">
<div role="alert">
<h2 className="text-base font-medium text-rose-900 dark:text-rose-100">
Unable to load stats
</h2>
<p className="mt-2 text-sm text-rose-700 dark:text-rose-300">
{error}
</p>
</div>
<Button
type="button"
variant="secondary"
className="mt-4"
onClick={refetch}
>
Retry
</Button>
</section>
)}
{status === 'success' && data && data.totalPairs === 0 && (
<EmptyState
title="No pairs yet"
description="Register a pair to see metrics."
/>
)}
<section
aria-live="polite"
aria-atomic="true"
aria-busy={status === 'loading'}
className="contents"
>
{status === 'loading' && (
<div className="flex items-center gap-2 text-sm">
<Spinner label="Loading stats" />
Loading…
</div>
)}
{status === 'success' && data && data.totalPairs === 0 && (
<EmptyState
title="No stats available yet"
description="Register a pair to start seeing router metrics."
/>
)}
{status === 'success' && data && data.totalPairs > 0 && (
<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'}
/>
</dl>
{lastUpdatedAt !== null && (
<LastUpdated timestamp={lastUpdatedAt} />
)}
<div className="mt-4 flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => downloadStatsSnapshot(data, 'json')}
>
Download JSON
</Button>
<Button
type="button"
variant="secondary"
onClick={() => downloadStatsSnapshot(data, 'csv')}
>
Download CSV
</Button>
</div>
</section>
)}
</section>
</main>
);
}
93 changes: 84 additions & 9 deletions src/app/stats/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import StatsPage from './page';
import {
buildStatsSnapshot,
Expand All @@ -31,7 +32,7 @@ describe('StatsPage', () => {
mockFetch({ totalPairs: 0, paused: false });
render(<StatsPage />);
expect(screen.getByRole('heading', { name: /stats/i })).toBeInTheDocument();
await screen.findByText('Live');
await screen.findByText(/no stats available yet/i);
});

it('renders one canonical stats page region and heading', async () => {
Expand All @@ -40,7 +41,7 @@ describe('StatsPage', () => {

expect(screen.getAllByRole('heading', { name: /stats/i })).toHaveLength(1);
expect(document.querySelectorAll('#main-content')).toHaveLength(1);
await screen.findByText('Live');
await screen.findByText(/no stats available yet/i);
});

it('names the metrics panel with an accessible region', async () => {
Expand All @@ -62,26 +63,100 @@ describe('StatsPage', () => {
});

it('renders Live when paused is false', async () => {
mockFetch({ totalPairs: 0, paused: false });
mockFetch({ totalPairs: 1, paused: false });
render(<StatsPage />);
const status = await screen.findByText('Live');
expect(status).toBeInTheDocument();
});

it('renders Paused when paused is true', async () => {
mockFetch({ totalPairs: 0, paused: true });
mockFetch({ totalPairs: 1, paused: true });
render(<StatsPage />);
const status = await screen.findByText('Paused');
expect(status).toBeInTheDocument();
});

it('renders error message on fetch failure', async () => {
it('renders a distinct error state on fetch failure', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
render(<StatsPage />);
await waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent(/network request failed/i);
});

const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent(/unable to load stats/i);
expect(alert).toHaveTextContent(/network request failed/i);
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
expect(
screen.queryByText(/no stats available yet/i)
).not.toBeInTheDocument();
expect(
screen.queryByRole('region', { name: /router metrics/i })
).not.toBeInTheDocument();
});

it('renders an empty state instead of metrics when no stats are available', async () => {
mockFetch({ totalPairs: 0, paused: false });
render(<StatsPage />);

expect(
await screen.findByText(/no stats available yet/i)
).toBeInTheDocument();
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(
screen.queryByRole('region', { name: /router metrics/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /download json/i })
).not.toBeInTheDocument();
});

it('keeps the loading state exclusive while the request is pending', () => {
global.fetch = jest.fn().mockReturnValue(new Promise(() => {}));
render(<StatsPage />);

expect(screen.getByText('Loading…')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(
screen.queryByText(/no stats available yet/i)
).not.toBeInTheDocument();
expect(
screen.queryByRole('region', { name: /router metrics/i })
).not.toBeInTheDocument();
});

it('announces fetch state changes in one polite live region', async () => {
mockFetch({ totalPairs: 0, paused: false });
render(<StatsPage />);

const liveRegion = document.querySelector('[aria-live="polite"]');
expect(liveRegion).toHaveAttribute('aria-atomic', 'true');
expect(liveRegion).toHaveAttribute('aria-busy', 'true');
expect(document.querySelectorAll('[aria-live="polite"]')).toHaveLength(1);

await screen.findByText(/no stats available yet/i);
expect(liveRegion).toHaveAttribute('aria-busy', 'false');
});

it('retries the request from the keyboard and renders recovered stats', async () => {
const user = userEvent.setup();
global.fetch = jest
.fn()
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(JSON.stringify({ totalPairs: 4, paused: false })),
} as unknown as Response);
render(<StatsPage />);

const retry = await screen.findByRole('button', { name: /retry/i });
retry.focus();
await user.keyboard('{Enter}');

expect(await screen.findByText('4')).toBeInTheDocument();
expect(screen.getByText('Live')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(global.fetch).toHaveBeenCalledTimes(2);
});

it('keeps the existing 5 second polling update behavior', async () => {
Expand Down
Loading