diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index ae67dc0..71acd71 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -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();`, }, }, diff --git a/src/app/pairs/Client.tsx b/src/app/pairs/Client.tsx index b109e6c..8098b71 100644 --- a/src/app/pairs/Client.tsx +++ b/src/app/pairs/Client.tsx @@ -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() { @@ -77,7 +78,12 @@ export default function PairsClient() { Pairs {pairs !== null && ( - {pairs.length} pair{pairs.length !== 1 ? 's' : ''} + + {`${formatCompactNumber(pairs.length)} pair${pairs.length !== 1 ? 's' : ''}`} + )} diff --git a/src/app/pairs/PairsDrawer.test.tsx b/src/app/pairs/PairsDrawer.test.tsx index e0de639..f50c759 100644 --- a/src/app/pairs/PairsDrawer.test.tsx +++ b/src/app/pairs/PairsDrawer.test.tsx @@ -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'; @@ -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(); 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(); - 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(); 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( {}} />); + + 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'); + }); }); diff --git a/src/app/pairs/PairsDrawer.tsx b/src/app/pairs/PairsDrawer.tsx index 0faca3c..9a25b20 100644 --- a/src/app/pairs/PairsDrawer.tsx +++ b/src/app/pairs/PairsDrawer.tsx @@ -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; @@ -111,6 +112,20 @@ export function PairsDrawer({ pair, onClose }: Props) {
{pair.destination}
+ {pair.volume !== undefined && ( +
+

+ 24h Volume +

+
+ {formatCompactNumber(pair.volume)} +
+
+ )} diff --git a/src/app/stats/Client.tsx b/src/app/stats/Client.tsx index 0ceb24b..61e1470 100644 --- a/src/app/stats/Client.tsx +++ b/src/app/stats/Client.tsx @@ -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'; @@ -74,7 +78,7 @@ export function buildStatsSnapshot( { label: 'Pairs', value: data.totalPairs, - display: formatNumber(data.totalPairs), + display: formatCompactNumber(data.totalPairs), }, { label: 'Status', @@ -193,7 +197,12 @@ export default function StatsClient() { Router metrics
- +
{lastUpdatedAt !== null && } diff --git a/src/app/stats/page.test.tsx b/src/app/stats/page.test.tsx index 17c8de8..409385c 100644 --- a/src/app/stats/page.test.tsx +++ b/src/app/stats/page.test.tsx @@ -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(); - 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 () => { @@ -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); }); @@ -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' }, ]); }); @@ -259,7 +262,7 @@ 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' @@ -267,7 +270,7 @@ describe('statsSnapshotToCsv', () => { 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', () => { diff --git a/src/components/StatTile.tsx b/src/components/StatTile.tsx index cba548e..c8deeb0 100644 --- a/src/components/StatTile.tsx +++ b/src/components/StatTile.tsx @@ -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'; @@ -18,6 +20,8 @@ const trendClass: Record, string> = { export function StatTile({ label, value, + title, + 'aria-label': ariaLabel, trend, trendDirection = 'neutral', }: Props) { @@ -26,7 +30,11 @@ export function StatTile({
{label}
-
+
{value} {trend !== undefined && ( diff --git a/src/lib/__tests__/format.test.ts b/src/lib/__tests__/format.test.ts index 945272c..617f6e6 100644 --- a/src/lib/__tests__/format.test.ts +++ b/src/lib/__tests__/format.test.ts @@ -1,4 +1,6 @@ import { + formatCompactNumber, + formatCompactNumberDisplay, formatNumber, formatQuoteAmountDisplay, formatQuoteRateDisplay, @@ -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'); }); diff --git a/src/lib/format.ts b/src/lib/format.ts index 7577179..1b34d82 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -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; diff --git a/src/lib/types.ts b/src/lib/types.ts index db1b4f8..6037359 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -14,6 +14,7 @@ export type Pair = { source: string; destination: string; + volume?: number; }; // ============================================================================ diff --git a/src/lib/validate.ts b/src/lib/validate.ts index c7bee13..5f49771 100644 --- a/src/lib/validate.ts +++ b/src/lib/validate.ts @@ -80,6 +80,11 @@ export function isPair(v: unknown): v is Pair { if (!isObject(v)) return false; if (typeof v.source !== 'string') return false; if (typeof v.destination !== 'string') return false; + if ( + v.volume !== undefined && + (typeof v.volume !== 'number' || !Number.isFinite(v.volume)) + ) + return false; return true; } @@ -108,6 +113,14 @@ export function parsePair(v: unknown): Pair { describeType(v.destination) ); } + if (v.volume !== undefined && !isFiniteNumber(v.volume)) { + throw new ValidationError( + 'Expected finite number', + 'volume', + 'number', + describeType(v.volume) + ); + } return v as unknown as Pair; }