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
2 changes: 1 addition & 1 deletion src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const data = await res.json();`,
dest_asset: "EURC",
amount: "100",
});
const res = await fetch(\`http://localhost:3001/api/v1/quote?\${params}\`);
const res = await fetch("http://localhost:3001/api/v1/quote?" + params);
const quote = await res.json();`,
},
},
Expand Down
10 changes: 8 additions & 2 deletions src/app/pairs/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ import { Spinner } from '@/components/Spinner';
import { useToast } from '@/components/ToastProvider';
import { apiDelete } from '@/lib/apiClient';
import { writeToClipboard } from '@/lib/clipboard';
import { formatCompactNumber, formatNumber } from '@/lib/format';
import { useApi } from '@/lib/useApi';
import { useColumnVisibility } from '@/lib/useColumnVisibility';
import { isPairsResponse } from '@/lib/validate';
import { filterPairs, groupBySource } from './pairsUtils';
import { type Pair } from '@/lib/types';
import { isPairsResponse } from '@/lib/validate';
import { PairsDrawer } from './PairsDrawer';

export default function PairsClient() {
Expand Down Expand Up @@ -77,7 +78,12 @@ export default function PairsClient() {
Pairs
{pairs !== null && (
<Badge variant="neutral">
{pairs.length} pair{pairs.length !== 1 ? 's' : ''}
<span
title={formatNumber(pairs.length)}
aria-label={`${formatNumber(pairs.length)} pair${pairs.length !== 1 ? 's' : ''}`}
>
{`${formatCompactNumber(pairs.length)} pair${pairs.length !== 1 ? 's' : ''}`}
</span>
</Badge>
)}
</span>
Expand Down
43 changes: 25 additions & 18 deletions src/app/pairs/PairsDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render, screen, fireEvent } from '@testing-library/react';
import { useState } from 'react';
import { PairsDrawer } from './PairsDrawer';
import { type Pair } from '@/lib/types';
Expand All @@ -20,61 +19,69 @@ function TestWrapper() {
}

describe('PairsDrawer', () => {
it('opens with details and closes on Escape', async () => {
const user = userEvent.setup();
it('opens with details and closes on Escape', () => {
render(<TestWrapper />);

const trigger = screen.getByRole('button', { name: 'Open Details' });
await user.click(trigger);
fireEvent.click(trigger);

const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument();
expect(screen.getByText('EUR')).toBeInTheDocument();
expect(screen.getByText('USD')).toBeInTheDocument();

await user.keyboard('{Escape}');
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('traps focus inside the drawer', async () => {
const user = userEvent.setup();
it('traps focus inside the drawer', () => {
render(<TestWrapper />);

await user.click(screen.getByRole('button', { name: 'Open Details' }));
fireEvent.click(screen.getByRole('button', { name: 'Open Details' }));

const closeButton = screen.getByRole('button', { name: 'Close details' });
const panel = screen.getByRole('dialog').firstChild as HTMLElement;

// Initial focus is on the panel (tabIndex="-1")
const panel = screen.getByRole('dialog').firstChild as HTMLElement;
expect(document.activeElement).toBe(panel);

// Tab moves to the first focusable element (close button)
await user.tab();
// Focus the first element inside panel
closeButton.focus();
expect(document.activeElement).toBe(closeButton);

// Tabbing past the last element loops back to the first
await user.tab();
fireEvent.keyDown(panel, { key: 'Tab' });
expect(document.activeElement).toBe(closeButton);

// Shift+Tab from first loops to last
await user.keyboard('{Shift>}{Tab}{/Shift}');
fireEvent.keyDown(panel, { key: 'Tab', shiftKey: true });
expect(document.activeElement).toBe(closeButton);
});

it('returns focus to the trigger upon closing', async () => {
const user = userEvent.setup();
it('returns focus to the trigger upon closing', () => {
render(<TestWrapper />);

const trigger = screen.getByRole('button', { name: 'Open Details' });
trigger.focus();
await user.click(trigger);
fireEvent.click(trigger);

expect(screen.getByRole('dialog')).toBeInTheDocument();

const closeButton = screen.getByRole('button', { name: 'Close details' });
await user.click(closeButton);
fireEvent.click(closeButton);

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(document.activeElement).toBe(trigger);
});

it('renders volume formatted compactly with title and aria-label when volume is provided', () => {
const pair: Pair = { source: 'USDC', destination: 'EURC', volume: 1500000 };
render(<PairsDrawer pair={pair} onClose={() => {}} />);

expect(screen.getByText('24h Volume')).toBeInTheDocument();
const volDisplay = screen.getByText('1.5M');
expect(volDisplay).toBeInTheDocument();
expect(volDisplay).toHaveAttribute('title', '1,500,000');
expect(volDisplay).toHaveAttribute('aria-label', '1,500,000');
});
});
15 changes: 15 additions & 0 deletions src/app/pairs/PairsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useRef } from 'react';
import { type Pair } from '@/lib/types';
import { IconButton } from '@/components/IconButton';
import { formatCompactNumber, formatNumber } from '@/lib/format';

type Props = {
pair: Pair | null;
Expand Down Expand Up @@ -111,6 +112,20 @@ export function PairsDrawer({ pair, onClose }: Props) {
</h3>
<div className="mt-1 font-mono text-lg">{pair.destination}</div>
</section>
{pair.volume !== undefined && (
<section>
<h3 className="text-sm font-medium text-neutral-500">
24h Volume
</h3>
<div
className="mt-1 font-mono text-lg"
title={formatNumber(pair.volume)}
aria-label={formatNumber(pair.volume)}
>
{formatCompactNumber(pair.volume)}
</div>
</section>
)}
</div>
</div>
</div>
Expand Down
15 changes: 12 additions & 3 deletions src/app/stats/Client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import { useEffect, useState } from 'react';
import { useApi } from '@/lib/useApi';
import { formatNumber, formatTimestamp } from '@/lib/format';
import {
formatCompactNumber,
formatNumber,
formatTimestamp,
} from '@/lib/format';
import { Button } from '@/components/Button';
import { EmptyState } from '@/components/EmptyState';
import { Spinner } from '@/components/Spinner';
Expand Down Expand Up @@ -74,7 +78,7 @@ export function buildStatsSnapshot(
{
label: 'Pairs',
value: data.totalPairs,
display: formatNumber(data.totalPairs),
display: formatCompactNumber(data.totalPairs),
},
{
label: 'Status',
Expand Down Expand Up @@ -193,7 +197,12 @@ export default function StatsClient() {
Router metrics
</h2>
<dl className="grid grid-cols-2 gap-4">
<StatTile label="Pairs" value={formatNumber(data.totalPairs)} />
<StatTile
label="Pairs"
value={formatCompactNumber(data.totalPairs)}
title={formatNumber(data.totalPairs)}
aria-label={formatNumber(data.totalPairs)}
/>
<StatTile label="Status" value={data.paused ? 'Paused' : 'Live'} />
</dl>
{lastUpdatedAt !== null && <LastUpdated timestamp={lastUpdatedAt} />}
Expand Down
15 changes: 9 additions & 6 deletions src/app/stats/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,14 @@ describe('StatsPage', () => {
});
});

it('formats totalPairs with thousands separators via formatNumber', async () => {
it('formats totalPairs with compact notation and exposes precise value in title/aria', async () => {
mockFetch({ totalPairs: 1234567, paused: false });
render(<StatsPage />);
const pairs = await screen.findByText('1,234,567');
const pairs = await screen.findByText('1.2M');
expect(pairs).toBeInTheDocument();
const dd = pairs.closest('dd');
expect(dd).toHaveAttribute('title', '1,234,567');
expect(dd).toHaveAttribute('aria-label', '1,234,567');
});

it('renders Live when paused is false', async () => {
Expand Down Expand Up @@ -108,7 +111,7 @@ describe('StatsPage', () => {
jest.advanceTimersByTime(5000);
});

expect(await screen.findByText('2,000')).toBeInTheDocument();
expect(await screen.findByText('2K')).toBeInTheDocument();
expect(await screen.findByText('Paused')).toBeInTheDocument();
expect(global.fetch).toHaveBeenCalledTimes(2);
});
Expand Down Expand Up @@ -207,7 +210,7 @@ describe('buildStatsSnapshot', () => {

expect(snapshot.capturedAt).toBe('2026-07-21T00:00:00.000Z');
expect(snapshot.metrics).toEqual([
{ label: 'Pairs', value: 1234567, display: '1,234,567' },
{ label: 'Pairs', value: 1234567, display: '1.2M' },
{ label: 'Status', value: 0, display: 'Live' },
]);
});
Expand Down Expand Up @@ -259,15 +262,15 @@ describe('statsSnapshotToCsv', () => {
expect(lines[2]).toBe('Status,0,Live,2026-07-21T00:00:00.000Z');
});

it('quotes display values that contain commas from thousands separators', () => {
it('formats display values compactly in CSV', () => {
const snapshot = buildStatsSnapshot(
{ totalPairs: 1234567, paused: false },
'2026-07-21T00:00:00.000Z'
);
const csv = statsSnapshotToCsv(snapshot);
const lines = csv.split('\n');

expect(lines[1]).toBe('Pairs,1234567,"1,234,567",2026-07-21T00:00:00.000Z');
expect(lines[1]).toBe('Pairs,1234567,1.2M,2026-07-21T00:00:00.000Z');
});

it('escapes embedded quote characters by doubling them', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/components/StatTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { type ReactNode } from 'react';
type Props = {
label: ReactNode;
value: ReactNode;
title?: string;
'aria-label'?: string;
/** Optional signed delta shown beside the value (e.g. +12%). */
trend?: ReactNode;
trendDirection?: 'up' | 'down' | 'neutral';
Expand All @@ -18,6 +20,8 @@ const trendClass: Record<NonNullable<Props['trendDirection']>, string> = {
export function StatTile({
label,
value,
title,
'aria-label': ariaLabel,
trend,
trendDirection = 'neutral',
}: Props) {
Expand All @@ -26,7 +30,11 @@ export function StatTile({
<dt className="text-xs uppercase tracking-wide text-neutral-500">
{label}
</dt>
<dd className="mt-1 flex items-baseline justify-center gap-2 text-2xl font-semibold">
<dd
className="mt-1 flex items-baseline justify-center gap-2 text-2xl font-semibold"
title={title}
aria-label={ariaLabel}
>
{value}
{trend !== undefined && (
<span className={`text-sm font-medium ${trendClass[trendDirection]}`}>
Expand Down
67 changes: 67 additions & 0 deletions src/lib/__tests__/format.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
formatCompactNumber,
formatCompactNumberDisplay,
formatNumber,
formatQuoteAmountDisplay,
formatQuoteRateDisplay,
Expand All @@ -16,6 +18,71 @@ describe('format', () => {
it('formatNumber adds separators', () => {
expect(formatNumber(1234567)).toBe('1,234,567');
});
describe('formatCompactNumber', () => {
it('leaves small numbers unchanged', () => {
expect(formatCompactNumber(0)).toBe('0');
expect(formatCompactNumber(123)).toBe('123');
expect(formatCompactNumber(999)).toBe('999');
});

it('formats thousands at boundary (1,000 to K)', () => {
expect(formatCompactNumber(1_000)).toBe('1K');
expect(formatCompactNumber(1_200)).toBe('1.2K');
expect(formatCompactNumber(999_499)).toBe('999.5K');
});

it('formats millions at boundary (1,000,000 to M)', () => {
expect(formatCompactNumber(1_000_000)).toBe('1M');
expect(formatCompactNumber(1_500_000)).toBe('1.5M');
expect(formatCompactNumber(12_345_678)).toBe('12.3M');
});

it('formats billions at boundary (1,000,000,000 to B)', () => {
expect(formatCompactNumber(1_000_000_000)).toBe('1B');
expect(formatCompactNumber(2_500_000_000)).toBe('2.5B');
});

it('handles negative values', () => {
expect(formatCompactNumber(-999)).toBe('-999');
expect(formatCompactNumber(-1_000)).toBe('-1K');
expect(formatCompactNumber(-1_500_000)).toBe('-1.5M');
expect(formatCompactNumber(-2_500_000_000)).toBe('-2.5B');
});

it('handles non-finite numbers', () => {
expect(formatCompactNumber(NaN)).toBe('NaN');
expect(formatCompactNumber(Infinity)).toBe('Infinity');
});

it('supports custom locales', () => {
expect(formatCompactNumber(1_500_000, 'de-DE')).toBe('1,5\u00a0Mio.');
});
});

describe('formatCompactNumberDisplay', () => {
it('returns compact display and formatted title', () => {
expect(formatCompactNumberDisplay(1_234_567)).toEqual({
display: '1.2M',
title: '1,234,567',
});
expect(formatCompactNumberDisplay(999)).toEqual({
display: '999',
title: '999',
});
expect(formatCompactNumberDisplay(-1_000)).toEqual({
display: '-1K',
title: '-1,000',
});
});

it('handles non-finite numbers gracefully', () => {
expect(formatCompactNumberDisplay(NaN)).toEqual({
display: 'NaN',
title: 'NaN',
});
});
});

it('formatTime returns HH:MM:SS', () => {
expect(formatTime(0)).toBe('00:00:00');
});
Expand Down
32 changes: 32 additions & 0 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,38 @@ export function formatNumber(n: number): string {
return n.toLocaleString('en-US');
}

/** Format a number into compact notation (e.g. 1K, 1.2M, 1B). Locale-aware. */
export function formatCompactNumber(
n: number,
locale: string = 'en-US',
options?: Intl.NumberFormatOptions
): string {
if (!Number.isFinite(n)) return String(n);
return new Intl.NumberFormat(locale, {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 1,
...options,
}).format(n);
}

/** Human-readable compact number with the raw/full formatted string preserved in title. */
export function formatCompactNumberDisplay(
n: number,
locale: string = 'en-US'
): {
display: string;
title: string;
} {
if (!Number.isFinite(n)) {
return { display: String(n), title: String(n) };
}
return {
display: formatCompactNumber(n, locale),
title: formatNumber(n),
};
}

/** Human-readable quote amount with the raw base-unit string preserved for operators. */
export function formatQuoteAmountDisplay(amount: string): {
display: string;
Expand Down
Loading
Loading