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
14 changes: 12 additions & 2 deletions src/components/common/KeyStatsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import Skeleton from '@/components/ui/skeleton';
import { AccessibleInfoTrigger } from '@/components/common/AccessibleInfoTrigger';
import { KEY_STAT_DEFINITIONS } from '@/components/common/keyStatDefinitions';
import type { KeyStats } from '@/services/course.service';
import UniqueTradersStat from '@/components/common/UniqueTradersStat';
import type { KeyStats, KeyUniqueTraders } from '@/services/course.service';

interface KeyStatsPanelProps {
stats?: KeyStats | null;
/** True only during the initial fetch (no data yet). */
isLoading?: boolean;
isError?: boolean;
/** Unique trader count, fetched separately on a 5-minute interval (#1020). */
uniqueTraders?: KeyUniqueTraders | null;
isUniqueTradersLoading?: boolean;
}

/**
Expand All @@ -21,6 +25,8 @@ const KeyStatsPanel: React.FC<KeyStatsPanelProps> = ({
stats,
isLoading = false,
isError = false,
uniqueTraders,
isUniqueTradersLoading = false,
}) => {
const showSkeleton = isLoading && !stats;
const showError = isError && !stats && !isLoading;
Expand All @@ -38,7 +44,7 @@ const KeyStatsPanel: React.FC<KeyStatsPanelProps> = ({
>
Key Stats
</h2>
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:grid-cols-7">
{KEY_STAT_DEFINITIONS.map(stat => (
<div
key={stat.key}
Expand Down Expand Up @@ -66,6 +72,10 @@ const KeyStatsPanel: React.FC<KeyStatsPanelProps> = ({
</dd>
</div>
))}
<UniqueTradersStat
data={uniqueTraders}
isLoading={isUniqueTradersLoading}
/>
</dl>
{showSkeleton && (
<span role="status" className="sr-only">
Expand Down
99 changes: 99 additions & 0 deletions src/components/common/UniqueTradersStat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import Skeleton from '@/components/ui/skeleton';
import { AccessibleInfoTrigger } from '@/components/common/AccessibleInfoTrigger';
import { formatNumber } from '@/utils/numberFormat.utils';
import {
getUniqueTradersTrend,
type UniqueTradersTrend,
} from '@/utils/uniqueTraders.utils';
import type { KeyUniqueTraders } from '@/services/course.service';

export const UNIQUE_TRADERS_LABEL = 'Unique Traders';
export const UNIQUE_TRADERS_EXPLANATION =
'Number of distinct wallets that have bought or sold this key at least once.';

interface UniqueTradersStatProps {
data?: KeyUniqueTraders | null;
/** True only during the initial fetch (no data yet). */
isLoading?: boolean;
}

const TREND_STYLES = {
up: { Icon: ArrowUpRight, className: 'text-emerald-300', word: 'Up' },
down: { Icon: ArrowDownRight, className: 'text-rose-300', word: 'Down' },
flat: { Icon: Minus, className: 'text-white/40', word: 'No change' },
} as const;

/**
* Unique trader count cell for the key stats panel (#1020). Uses the same
* fixed-height cell as the other stats so the 5-minute refreshes never shift
* the layout.
*/
const UniqueTradersStat: React.FC<UniqueTradersStatProps> = ({
data,
isLoading = false,
}) => {
const showSkeleton = isLoading && !data;
const current = data?.uniqueTraders;
const trend = getUniqueTradersTrend(current, data?.uniqueTraders24hAgo);

return (
<div
className="min-w-0 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4"
data-testid="key-stat-uniqueTraders"
>
<dt className="flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-[0.22em] text-white/40">
<span className="truncate">{UNIQUE_TRADERS_LABEL}</span>
<AccessibleInfoTrigger
explanation={UNIQUE_TRADERS_EXPLANATION}
label={`Explanation for: ${UNIQUE_TRADERS_LABEL}`}
/>
</dt>
<dd className="mt-2.5 flex h-7 items-center gap-2 font-jakarta text-base font-bold tabular-nums text-white md:text-[1.05rem]">
{showSkeleton ? (
<Skeleton className="h-5 w-20" />
) : (
<>
<span
className="truncate"
data-testid="key-stat-uniqueTraders-value"
>
{formatNumber(current)}
</span>
{trend && <TrendIndicator {...trend} />}
</>
)}
</dd>
</div>
);
};

const TrendIndicator: React.FC<UniqueTradersTrend> = ({ direction, delta }) => {
const { Icon, className, word } = TREND_STYLES[direction];
const label =
direction === 'flat'
? 'No change vs previous 24 hours'
: `${word} ${formatNumber(delta)} vs previous 24 hours`;
const sign = direction === 'up' ? '+' : direction === 'down' ? '−' : '';

return (
<span
className={`flex shrink-0 items-center gap-0.5 text-xs font-semibold ${className}`}
data-testid="key-stat-uniqueTraders-trend"
data-direction={direction}
role="img"
aria-label={label}
title={label}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{direction !== 'flat' && (
<span aria-hidden="true">
{sign}
{formatNumber(delta)}
</span>
)}
</span>
);
};

export default UniqueTradersStat;
22 changes: 19 additions & 3 deletions src/components/common/__tests__/KeyStatsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,26 @@ describe('KeyStatsPanel', () => {
expect(grid).toHaveClass(
'grid',
'grid-cols-2',
'sm:grid-cols-3',
'lg:grid-cols-6'
'sm:grid-cols-4',
'xl:grid-cols-7'
);
expect(within(grid as HTMLElement).getAllByRole('term')).toHaveLength(6);
// Six aggregated stats plus the unique traders cell (#1020).
expect(within(grid as HTMLElement).getAllByRole('term')).toHaveLength(7);
});

it('renders the unique traders stat alongside holders and total volume', () => {
render(
<KeyStatsPanel
stats={stats}
uniqueTraders={{ uniqueTraders: 312, uniqueTraders24hAgo: 300 }}
/>
);
expect(
screen.getByTestId('key-stat-uniqueTraders-value')
).toHaveTextContent('312');
expect(
screen.getByTestId('key-stat-uniqueTraders-trend')
).toHaveAttribute('data-direction', 'up');
});

it('shows an error message when the initial fetch fails', () => {
Expand Down
104 changes: 104 additions & 0 deletions src/components/common/__tests__/UniqueTradersStat.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import UniqueTradersStat, {
UNIQUE_TRADERS_EXPLANATION,
} from '@/components/common/UniqueTradersStat';

const renderStat = (props: React.ComponentProps<typeof UniqueTradersStat>) =>
render(
<dl>
<UniqueTradersStat {...props} />
</dl>
);

describe('UniqueTradersStat', () => {
it('displays the unique trader count from the API', () => {
renderStat({ data: { uniqueTraders: 1234, uniqueTraders24hAgo: 1200 } });
expect(
screen.getByTestId('key-stat-uniqueTraders-value')
).toHaveTextContent('1,234');
expect(screen.getByText('Unique Traders')).toBeInTheDocument();
});

it('shows an upward trend vs the previous 24 hours', () => {
renderStat({ data: { uniqueTraders: 50, uniqueTraders24hAgo: 42 } });
const trend = screen.getByTestId('key-stat-uniqueTraders-trend');
expect(trend).toHaveAttribute('data-direction', 'up');
expect(trend).toHaveTextContent('+8');
expect(
screen.getByRole('img', { name: 'Up 8 vs previous 24 hours' })
).toBeInTheDocument();
});

it('shows a downward trend vs the previous 24 hours', () => {
renderStat({ data: { uniqueTraders: 40, uniqueTraders24hAgo: 42 } });
const trend = screen.getByTestId('key-stat-uniqueTraders-trend');
expect(trend).toHaveAttribute('data-direction', 'down');
expect(trend).toHaveTextContent('−2');
expect(
screen.getByRole('img', { name: 'Down 2 vs previous 24 hours' })
).toBeInTheDocument();
});

it('renders zero traders for a new key with a no-change trend', () => {
renderStat({ data: { uniqueTraders: 0, uniqueTraders24hAgo: 0 } });
expect(
screen.getByTestId('key-stat-uniqueTraders-value')
).toHaveTextContent('0');
expect(
screen.getByTestId('key-stat-uniqueTraders-trend')
).toHaveAttribute('data-direction', 'flat');
expect(
screen.getByRole('img', { name: 'No change vs previous 24 hours' })
).toBeInTheDocument();
});

it('hides the trend when the previous 24h value is unavailable', () => {
renderStat({ data: { uniqueTraders: 5, uniqueTraders24hAgo: null } });
expect(
screen.queryByTestId('key-stat-uniqueTraders-trend')
).not.toBeInTheDocument();
});

it('exposes an accessible tooltip explaining the metric', () => {
renderStat({ data: { uniqueTraders: 5, uniqueTraders24hAgo: 5 } });
const trigger = screen.getByRole('button', {
name: 'Explanation for: Unique Traders',
});
fireEvent.focus(trigger);
expect(screen.getByRole('tooltip')).toHaveTextContent(
UNIQUE_TRADERS_EXPLANATION
);
expect(UNIQUE_TRADERS_EXPLANATION).toMatch(/distinct wallets/);
expect(UNIQUE_TRADERS_EXPLANATION).toMatch(/bought or sold/);
});

it('keeps a fixed-height value slot between loading and loaded states', () => {
const { rerender } = renderStat({ isLoading: true });
const cell = screen.getByTestId('key-stat-uniqueTraders');
const loadingClass = cell.querySelector('dd')?.className;
expect(
screen.queryByTestId('key-stat-uniqueTraders-value')
).not.toBeInTheDocument();

rerender(
<dl>
<UniqueTradersStat
data={{ uniqueTraders: 3, uniqueTraders24hAgo: 1 }}
/>
</dl>
);
expect(cell.querySelector('dd')?.className).toBe(loadingClass);
expect(loadingClass).toContain('h-7');
});

it('keeps showing the value during a background refresh', () => {
renderStat({
data: { uniqueTraders: 7, uniqueTraders24hAgo: 7 },
isLoading: true,
});
expect(
screen.getByTestId('key-stat-uniqueTraders-value')
).toHaveTextContent('7');
});
});
98 changes: 98 additions & 0 deletions src/hooks/__tests__/useKeyUniqueTraders.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
import {
useKeyUniqueTraders,
KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS,
} from '@/hooks/useKeyUniqueTraders';
import {
courseService,
type KeyUniqueTraders,
} from '@/services/course.service';
import { queryKeys } from '@/lib/queryKeys';

vi.mock('@/services/course.service', () => ({
courseService: {
getKeyUniqueTraders: vi.fn(),
},
}));

const mockGet = vi.mocked(courseService.getKeyUniqueTraders);

const base: KeyUniqueTraders = { uniqueTraders: 20, uniqueTraders24hAgo: 18 };

describe('useKeyUniqueTraders', () => {
let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockGet.mockReset();
});

afterEach(() => {
vi.useRealTimers();
queryClient.clear();
});

const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);

it('fetches the count for the key and refreshes every 5 minutes', async () => {
mockGet.mockResolvedValue(base);

const { result } = renderHook(() => useKeyUniqueTraders('key-1'), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.data).toEqual(base);
expect(mockGet).toHaveBeenCalledWith('key-1');

const query = queryClient
.getQueryCache()
.find({ queryKey: queryKeys.creators.uniqueTraders('key-1') });
expect(KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS).toBe(300_000);
expect(query?.options.staleTime).toBe(300_000);
expect(query?.options.refetchInterval).toBe(300_000);
});

it('refetches after 5 minutes and keeps previous data while refreshing', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
mockGet.mockResolvedValueOnce(base);

const { result } = renderHook(() => useKeyUniqueTraders('key-1'), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));

const updated = { ...base, uniqueTraders: 21 };
let resolveRefetch!: (value: KeyUniqueTraders) => void;
mockGet.mockReturnValueOnce(
new Promise<KeyUniqueTraders>(resolve => {
resolveRefetch = resolve;
})
);

await act(async () => {
await vi.advanceTimersByTimeAsync(300_000);
});

expect(mockGet).toHaveBeenCalledTimes(2);
expect(result.current.isLoading).toBe(false);
expect(result.current.data).toEqual(base);

await act(async () => {
resolveRefetch(updated);
});
await waitFor(() => expect(result.current.data).toEqual(updated));
});

it('does not fetch when the key id is empty', () => {
renderHook(() => useKeyUniqueTraders(''), { wrapper });
expect(mockGet).not.toHaveBeenCalled();
});
});
17 changes: 17 additions & 0 deletions src/hooks/useKeyUniqueTraders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { courseService } from '@/services/course.service';

/** How often the unique trader count is refreshed (#1020). */
export const KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS = 5 * 60_000;

export function useKeyUniqueTraders(keyId: string) {
return useQuery({
queryKey: queryKeys.creators.uniqueTraders(keyId),
queryFn: () => courseService.getKeyUniqueTraders(keyId),
enabled: !!keyId,
staleTime: KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS,
refetchInterval: KEY_UNIQUE_TRADERS_REFRESH_INTERVAL_MS,
retry: false,
});
}
Loading
Loading