From 2eaa79a14bd19d2b2ebc12a113c48005e2d66b89 Mon Sep 17 00:00:00 2001 From: Yormee-103 Date: Sat, 17 Oct 2026 21:16:48 +0100 Subject: [PATCH] feat: liquidity depth indicator, holder snapshot history, graduation celebration, trade history table - #1108: show max buy within slippage tolerance on the buy form, warn near the limit, and disable Buy above it - #1107: holder snapshot history charts and stats card on the key detail page - #1110: graduation card with once-per-session confetti, tweet and referral link - #1114: paginated trade history table with filters and CSV export of all matching rows on the portfolio page Closes #1108, closes #1107, closes #1110, closes #1114 Co-Authored-By: Claude Sonnet 5.5 --- .../common/GraduationCelebration.tsx | 180 ++++++++++ .../common/HolderSnapshotHistory.tsx | 253 +++++++++++++++ .../common/LiquidityDepthIndicator.tsx | 81 +++++ src/components/common/TradeDialog.tsx | 32 ++ src/components/common/TradeHistoryLedger.tsx | 307 ++++++++++++++++++ .../__tests__/GraduationCelebration.test.tsx | 185 +++++++++++ .../__tests__/HolderSnapshotHistory.test.tsx | 242 ++++++++++++++ .../TradeDialog.liquidityDepth.test.tsx | 131 ++++++++ .../__tests__/TradeHistoryLedger.test.tsx | 307 ++++++++++++++++++ src/hooks/useAllTradeHistory.ts | 44 +++ src/hooks/useHolderSnapshots.ts | 23 ++ src/lib/queryKeys.ts | 4 + src/pages/CreatorDetailPage.tsx | 25 ++ src/pages/ProfilePage.tsx | 7 +- .../ProfilePage.integration.test.tsx | 43 +-- src/services/holderSnapshots.service.ts | 42 +++ src/services/tradeHistory.service.ts | 2 + .../__tests__/liquidityDepth.utils.test.ts | 97 ++++++ src/utils/graduation.utils.ts | 120 +++++++ src/utils/holderSnapshots.utils.ts | 88 +++++ src/utils/liquidityDepth.utils.ts | 83 +++++ src/utils/tradeLedger.utils.ts | 128 ++++++++ 22 files changed, 2393 insertions(+), 31 deletions(-) create mode 100644 src/components/common/GraduationCelebration.tsx create mode 100644 src/components/common/HolderSnapshotHistory.tsx create mode 100644 src/components/common/LiquidityDepthIndicator.tsx create mode 100644 src/components/common/TradeHistoryLedger.tsx create mode 100644 src/components/common/__tests__/GraduationCelebration.test.tsx create mode 100644 src/components/common/__tests__/HolderSnapshotHistory.test.tsx create mode 100644 src/components/common/__tests__/TradeDialog.liquidityDepth.test.tsx create mode 100644 src/components/common/__tests__/TradeHistoryLedger.test.tsx create mode 100644 src/hooks/useAllTradeHistory.ts create mode 100644 src/hooks/useHolderSnapshots.ts create mode 100644 src/services/holderSnapshots.service.ts create mode 100644 src/utils/__tests__/liquidityDepth.utils.test.ts create mode 100644 src/utils/graduation.utils.ts create mode 100644 src/utils/holderSnapshots.utils.ts create mode 100644 src/utils/liquidityDepth.utils.ts create mode 100644 src/utils/tradeLedger.utils.ts diff --git a/src/components/common/GraduationCelebration.tsx b/src/components/common/GraduationCelebration.tsx new file mode 100644 index 00000000..1f1798f2 --- /dev/null +++ b/src/components/common/GraduationCelebration.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { GraduationCap, Share2, Link as LinkIcon, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { useGraduatedCurveConfig } from '@/hooks/useGraduatedCurveConfig'; +import { playPurchaseConfetti } from '@/utils/confetti.utils'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { + buildGraduationReferralLink, + buildGraduationStats, + buildGraduationTweetUrl, + hasCelebratedThisSession, + hasKeyGraduated, + markCelebratedThisSession, +} from '@/utils/graduation.utils'; + +export interface GraduationCelebrationProps { + keyId: string; + keyName: string; + currentSupply?: number | null; + holderCount?: number | null; + /** Connected wallet; included in the copied link as the referral code. */ + walletAddress?: string | null; + className?: string; +} + +/** + * Graduation card for the key detail page (#1110). Appears once the key's + * supply reaches its final curve milestone, plays confetti once per session + * per key, and offers a pre-filled share and a referral link. + */ +export const GraduationCelebration: React.FC = ({ + keyId, + keyName, + currentSupply, + holderCount, + walletAddress, + className, +}) => { + const { data: config } = useGraduatedCurveConfig(keyId); + const graduated = hasKeyGraduated(config, currentSupply); + const [copied, setCopied] = useState(false); + const played = useRef(false); + + useEffect(() => { + if (!graduated || played.current) return; + played.current = true; + if (hasCelebratedThisSession(keyId)) return; + markCelebratedThisSession(keyId); + playPurchaseConfetti(3000); + }, [graduated, keyId]); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + const stats = graduated ? buildGraduationStats(config, holderCount) : null; + if (!graduated || !stats) return null; + + const link = buildGraduationReferralLink(keyId, walletAddress); + const tweetUrl = buildGraduationTweetUrl({ keyName, stats, link }); + + const handleCopy = async () => { + try { + await copyTextToClipboard(link); + setCopied(true); + } catch { + setCopied(false); + } + }; + + return ( +
+
+
+

+ This key reached its final supply milestone. Thank you to every + holder. +

+ +
+
+
Final supply
+
+ {formatNumber(stats.finalSupply)} +
+
+
+
Milestones passed
+
+ {formatNumber(stats.milestonesReached)} +
+
+
+
Price at graduation
+
+ {stats.graduationPriceXlm != null + ? `${formatNumber(stats.graduationPriceXlm)} XLM` + : '—'} +
+
+
+
Holders
+
+ {stats.holderCount != null + ? formatNumber(stats.holderCount) + : '—'} +
+
+
+ +
+ + + + {copied ? 'Referral link copied to clipboard' : ''} + +
+
+ ); +}; + +export default GraduationCelebration; diff --git a/src/components/common/HolderSnapshotHistory.tsx b/src/components/common/HolderSnapshotHistory.tsx new file mode 100644 index 00000000..1377ce58 --- /dev/null +++ b/src/components/common/HolderSnapshotHistory.tsx @@ -0,0 +1,253 @@ +import React, { useMemo, useState } from 'react'; +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + Tooltip as ChartTooltip, + CartesianGrid, +} from 'recharts'; +import { cn } from '@/lib/utils'; +import Skeleton from '@/components/ui/skeleton'; +import { useHolderSnapshots } from '@/hooks/useHolderSnapshots'; +import type { HolderSnapshotRange } from '@/services/holderSnapshots.service'; +import { formatNumber, formatPercent } from '@/utils/numberFormat.utils'; +import { + HOLDER_SNAPSHOT_RANGES, + buildSnapshotPoints, + formatGini, + formatSnapshotDate, + hasEnoughSnapshotsForTrend, + latestSnapshot, + type HolderSnapshotPoint, +} from '@/utils/holderSnapshots.utils'; + +export interface HolderSnapshotHistoryProps { + keyId: string; + className?: string; + chartHeight?: number; +} + +interface SnapshotChartProps { + title: string; + testId: string; + points: HolderSnapshotPoint[]; + dataKey: 'holderCount' | 'top10SharePercent'; + valueFormatter: (value: number) => string; + color: string; + height: number; +} + +const SnapshotChart: React.FC = ({ + title, + testId, + points, + dataKey, + valueFormatter, + color, + height, +}) => ( +
+

{title}

+
+ + + + p.ts) : undefined} + tickFormatter={formatSnapshotDate} + stroke="rgba(255,255,255,0.4)" + fontSize={11} + /> + + formatSnapshotDate(Number(label))} + formatter={value => [valueFormatter(Number(value)), title]} + /> + + + +
+
+); + +/** + * Holder snapshot history for the key detail analytics section (#1107): + * holder count and top-10 concentration over time, with a current snapshot + * stats card. Data is cached for 5 minutes by `useHolderSnapshots`. + */ +export const HolderSnapshotHistory: React.FC = ({ + keyId, + className, + chartHeight = 220, +}) => { + const [range, setRange] = useState('30d'); + const { data, isLoading, isError } = useHolderSnapshots(keyId, range); + + const points = useMemo( + () => buildSnapshotPoints(data, range), + [data, range] + ); + const latest = latestSnapshot(points); + + return ( +
+
+
+ {HOLDER_SNAPSHOT_RANGES.map(option => ( + + ))} +
+
+ + {isLoading ? ( +
+ + +
+ ) : isError ? ( +

+ Couldn't load holder snapshots. Please try again later. +

+ ) : points.length === 0 ? ( +

+ No holder snapshots have been taken for this range yet. +

+ ) : ( + <> + {latest && ( +
+
+
Total holders
+
+ {formatNumber(latest.holderCount)} +
+
+
+
Top holder
+
+ {formatPercent(latest.topHolderPercent)} +
+
+
+
+ Gini coefficient +
+
+ {formatGini(latest.giniCoefficient)} +
+
+
+ )} + + {!hasEnoughSnapshotsForTrend(points) && ( +

+ Only {points.length} snapshot + {points.length !== 1 ? 's' : ''} so far — trends will fill + in as more are taken. +

+ )} + + formatNumber(value)} + color="#fbbf24" + height={chartHeight} + /> + formatPercent(value)} + color="#60a5fa" + height={chartHeight} + /> + + )} +
+ ); +}; + +export default HolderSnapshotHistory; diff --git a/src/components/common/LiquidityDepthIndicator.tsx b/src/components/common/LiquidityDepthIndicator.tsx new file mode 100644 index 00000000..e79986cd --- /dev/null +++ b/src/components/common/LiquidityDepthIndicator.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { AlertTriangle, Info } from 'lucide-react'; +import { Tooltip } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import type { LiquidityDepthStatus } from '@/utils/liquidityDepth.utils'; + +export interface LiquidityDepthIndicatorProps { + /** Max keys buyable within the user's slippage tolerance. */ + maxDepth: number; + /** Slippage tolerance in percent, used in the copy. */ + slippageTolerancePercent: number; + status: LiquidityDepthStatus; + className?: string; +} + +const TOOLTIP_COPY = + 'Liquidity depth is the most keys you can buy in one trade before the price moves more than your slippage tolerance. Buy fewer keys, split the order into several trades, or raise your slippage tolerance to buy more.'; + +/** + * Shows the max purchasable amount for the current slippage setting under the + * buy amount input, and warns as the entered amount nears or passes it (#1108). + */ +export const LiquidityDepthIndicator: React.FC< + LiquidityDepthIndicatorProps +> = ({ maxDepth, slippageTolerancePercent, status, className }) => { + const noDepth = maxDepth <= 0; + + return ( +
+
+ + Max buy at {slippageTolerancePercent}% slippage:{' '} + + {formatNumber(maxDepth)} key{maxDepth !== 1 ? 's' : ''} + + + + + +
+ + {status !== 'ok' && ( +
+
+ )} +
+ ); +}; + +export default LiquidityDepthIndicator; diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 71416bda..ec09eda4 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -70,6 +70,11 @@ import { computeSlippageBounds, type SlippageBounds, } from '@/utils/slippageTolerance.utils'; +import LiquidityDepthIndicator from '@/components/common/LiquidityDepthIndicator'; +import { + classifyLiquidityDepth, + computeMaxBuyWithinSlippage, +} from '@/utils/liquidityDepth.utils'; import { useGlobalPause } from '@/hooks/useGlobalPause'; import { UnavailableAction } from '@/components/ui/unavailable-action'; import { useContractPausedStore, selectIsPaused } from '@/hooks/useContractPausedStore'; @@ -428,6 +433,23 @@ const TradeDialog: React.FC = ({ side === 'buy' && circuitBreakerStatus?.isBreached ); + // Liquidity depth (#1108): max keys buyable within the slippage tolerance. + const liquidityMaxDepth = useMemo( + () => + side === 'buy' && currentSupply != null + ? computeMaxBuyWithinSlippage({ + currentSupply, + slippageTolerancePercent, + }) + : null, + [side, currentSupply, slippageTolerancePercent] + ); + const liquidityDepthStatus = + liquidityMaxDepth != null + ? classifyLiquidityDepth(parsedAmount, liquidityMaxDepth) + : 'ok'; + const exceedsLiquidityDepth = liquidityDepthStatus === 'exceeded'; + const isAllowanceChecking = side === 'stake' && allowanceStatus === 'checking'; const requiresAllowanceApproval = side === 'stake' && needsApproval; @@ -437,6 +459,7 @@ const TradeDialog: React.FC = ({ isApproving || isAllowanceChecking || isCircuitBreakerBreached || + exceedsLiquidityDepth || atCap || isPaused || requiresAllowanceApproval; @@ -950,6 +973,14 @@ const TradeDialog: React.FC = ({

)} + {side === 'buy' && liquidityMaxDepth != null && ( + + )} +
= ({ disabled={ !amountValid || isSubmitting || + exceedsLiquidityDepth || (side === 'buy' && (previewLoading || previewError != null)) || paused } diff --git a/src/components/common/TradeHistoryLedger.tsx b/src/components/common/TradeHistoryLedger.tsx new file mode 100644 index 00000000..22edbf56 --- /dev/null +++ b/src/components/common/TradeHistoryLedger.tsx @@ -0,0 +1,307 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Download, Search } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import Skeleton from '@/components/ui/skeleton'; +import EmptyState from '@/components/common/EmptyState'; +import { cn } from '@/lib/utils'; +import { useAllTradeHistory } from '@/hooks/useAllTradeHistory'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import { downloadCsv } from '@/utils/transactionHistory.utils'; +import { + EMPTY_TRADE_LEDGER_FILTERS, + TRADE_LEDGER_PAGE_SIZE, + buildTradeCsvFilename, + computeTradeAmounts, + filterTrades, + pageCount, + paginate, + tradesToCsv, + type TradeLedgerFilters, +} from '@/utils/tradeLedger.utils'; +import type { Trade } from '@/services/tradeHistory.service'; + +export interface TradeHistoryLedgerProps { + walletAddress: string; + className?: string; +} + +function formatXlm(value: number): string { + return `${formatNumber(value, { minimumFractionDigits: 2, maximumFractionDigits: 7 })} XLM`; +} + +function DirectionChip({ direction }: { direction: Trade['tradeType'] }) { + const isBuy = direction === 'Buy'; + return ( + + {direction} + + ); +} + +/** + * Paginated trade history for the portfolio page (#1114): date range and + * key-name filters, colour-coded Buy/Sell chips, and a CSV export that + * includes every matching row rather than only the current page. + */ +export const TradeHistoryLedger: React.FC = ({ + walletAddress, + className, +}) => { + const { + data: trades, + isLoading, + isError, + } = useAllTradeHistory(walletAddress); + const [filters, setFilters] = useState( + EMPTY_TRADE_LEDGER_FILTERS + ); + const [page, setPage] = useState(1); + + const filtered = useMemo( + () => filterTrades(trades ?? [], filters), + [trades, filters] + ); + const totalPages = pageCount(filtered.length); + const visible = useMemo(() => paginate(filtered, page), [filtered, page]); + + // A filter change can leave the current page past the end; go back to page 1. + useEffect(() => { + setPage(1); + }, [filters]); + + const updateFilter = (key: keyof TradeLedgerFilters, value: string) => + setFilters(prev => ({ ...prev, [key]: value })); + + const hasActiveFilters = + filters.from !== '' || filters.to !== '' || filters.search.trim() !== ''; + + const handleExport = () => { + downloadCsv(buildTradeCsvFilename(), tradesToCsv(filtered)); + }; + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (isError) { + return ( + + ); + } + + if (!trades || trades.length === 0) { + return ( + + ); + } + + return ( +
+
+ + + + +
+ + {filtered.length === 0 ? ( + + {hasActiveFilters && ( + + )} + + ) : ( + <> +
+ + + + + + + + + + + + + + {visible.map(trade => { + const { fee, total } = computeTradeAmounts(trade); + return ( + + + + + + + + + + ); + })} + +
+ Date + + Key + + Direction + + Amount + + Price + + Fee + + Total +
+ {new Date( + trade.timestamp + ).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + })} + {trade.keyName} + + + {formatNumber(trade.quantity)} + + {formatXlm(trade.pricePerKey)} + + {formatXlm(fee)} + + {formatXlm(total)} +
+
+ + + + )} +
+ ); +}; + +export default TradeHistoryLedger; diff --git a/src/components/common/__tests__/GraduationCelebration.test.tsx b/src/components/common/__tests__/GraduationCelebration.test.tsx new file mode 100644 index 00000000..b5e0fc73 --- /dev/null +++ b/src/components/common/__tests__/GraduationCelebration.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import GraduationCelebration from '@/components/common/GraduationCelebration'; +import { + GRADUATION_SESSION_PREFIX, + buildGraduationReferralLink, + buildGraduationStats, + buildGraduationTweetText, + getFinalMilestone, + hasKeyGraduated, +} from '@/utils/graduation.utils'; +import type { GraduatedCurveConfig } from '@/services/course.service'; + +const playPurchaseConfetti = vi.fn(); +vi.mock('@/utils/confetti.utils', () => ({ + playPurchaseConfetti: (...args: unknown[]) => playPurchaseConfetti(...args), +})); + +const copyTextToClipboard = vi.fn(); +vi.mock('@/utils/clipboard.utils', () => ({ + copyTextToClipboard: (...args: unknown[]) => copyTextToClipboard(...args), +})); + +let mockConfig: GraduatedCurveConfig | undefined; +vi.mock('@/hooks/useGraduatedCurveConfig', () => ({ + useGraduatedCurveConfig: () => ({ data: mockConfig }), +})); + +const CONFIG: GraduatedCurveConfig = { + hasGraduatedCurve: true, + milestones: [ + { supplyThreshold: 100, exponent: 1, simulatedPrice: 0.5 }, + { supplyThreshold: 1000, exponent: 2, simulatedPrice: 4.25 }, + { supplyThreshold: 500, exponent: 1.5, simulatedPrice: 2 }, + ], +}; + +describe('graduation utils (#1110)', () => { + it('uses the highest-supply milestone as the final one', () => { + expect(getFinalMilestone(CONFIG)?.supplyThreshold).toBe(1000); + expect(getFinalMilestone({ milestones: [] })).toBeNull(); + expect(getFinalMilestone(undefined)).toBeNull(); + }); + + it('graduates only once supply reaches the final milestone', () => { + expect(hasKeyGraduated(CONFIG, 999)).toBe(false); + expect(hasKeyGraduated(CONFIG, 1000)).toBe(true); + expect(hasKeyGraduated(CONFIG, 5000)).toBe(true); + expect(hasKeyGraduated(CONFIG, null)).toBe(false); + expect(hasKeyGraduated({ milestones: [] }, 5000)).toBe(false); + }); + + it('builds the milestone stats', () => { + expect(buildGraduationStats(CONFIG, 321)).toEqual({ + finalSupply: 1000, + graduationPriceXlm: 4.25, + milestonesReached: 3, + holderCount: 321, + }); + }); + + it('builds a tweet with the key name, stats and link', () => { + const stats = buildGraduationStats(CONFIG, 1234)!; + const text = buildGraduationTweetText({ + keyName: 'Alice Key', + stats, + link: 'https://accesslayer.app/creator/key-1', + }); + expect(text).toContain('Alice Key'); + expect(text).toContain('1,000 keys'); + expect(text).toContain('1,234 holders'); + expect(text).toContain('https://accesslayer.app/creator/key-1'); + }); + + it('builds the referral link with the wallet as ref', () => { + expect(buildGraduationReferralLink('key-1', 'GWALLET')).toMatch( + /\/creator\/key-1\?ref=GWALLET$/ + ); + expect(buildGraduationReferralLink('key-1', null)).toMatch( + /\/creator\/key-1$/ + ); + }); +}); + +describe('GraduationCelebration (#1110)', () => { + beforeEach(() => { + window.sessionStorage.clear(); + playPurchaseConfetti.mockReset(); + copyTextToClipboard.mockReset().mockResolvedValue(undefined); + mockConfig = CONFIG; + }); + + function renderCard( + props: Partial> = {} + ) { + return render( + + ); + } + + it('shows nothing before the key graduates, and no animation', () => { + renderCard({ currentSupply: 400 }); + expect(screen.queryByTestId('graduation-card')).toBeNull(); + expect(playPurchaseConfetti).not.toHaveBeenCalled(); + }); + + it('shows the graduation card with milestone stats and plays confetti', async () => { + renderCard(); + expect(screen.getByTestId('graduation-card')).toBeInTheDocument(); + expect(screen.getByTestId('graduation-stat-supply')).toHaveTextContent( + '1,000' + ); + expect( + screen.getByTestId('graduation-stat-milestones') + ).toHaveTextContent('3'); + expect(screen.getByTestId('graduation-stat-price')).toHaveTextContent( + '4.25 XLM' + ); + expect(screen.getByTestId('graduation-stat-holders')).toHaveTextContent( + '321' + ); + await waitFor(() => + expect(playPurchaseConfetti).toHaveBeenCalledTimes(1) + ); + }); + + it('plays the animation only once per session per key', async () => { + const first = renderCard(); + await waitFor(() => + expect(playPurchaseConfetti).toHaveBeenCalledTimes(1) + ); + expect( + window.sessionStorage.getItem(`${GRADUATION_SESSION_PREFIX}key-1`) + ).toBe('1'); + first.unmount(); + + renderCard(); + expect(screen.getByTestId('graduation-card')).toBeInTheDocument(); + expect(playPurchaseConfetti).toHaveBeenCalledTimes(1); + }); + + it('still celebrates a different key in the same session', async () => { + renderCard(); + await waitFor(() => + expect(playPurchaseConfetti).toHaveBeenCalledTimes(1) + ); + renderCard({ keyId: 'key-2' }); + await waitFor(() => + expect(playPurchaseConfetti).toHaveBeenCalledTimes(2) + ); + }); + + it('pre-fills the tweet with the key name, stats and link', () => { + renderCard(); + const href = screen + .getByTestId('graduation-share-tweet') + .getAttribute('href')!; + expect(href.startsWith('https://twitter.com/intent/tweet?text=')).toBe( + true + ); + const text = decodeURIComponent(href.split('text=')[1]); + expect(text).toContain('Alice Key'); + expect(text).toContain('1,000 keys'); + expect(text).toContain('321 holders'); + expect(text).toContain('/creator/key-1?ref=GWALLET'); + }); + + it('copies the referral link', async () => { + renderCard(); + fireEvent.click(screen.getByTestId('graduation-copy-link')); + await waitFor(() => expect(copyTextToClipboard).toHaveBeenCalledTimes(1)); + expect(copyTextToClipboard.mock.calls[0][0]).toMatch( + /\/creator\/key-1\?ref=GWALLET$/ + ); + expect(await screen.findByText('Link copied')).toBeInTheDocument(); + }); +}); diff --git a/src/components/common/__tests__/HolderSnapshotHistory.test.tsx b/src/components/common/__tests__/HolderSnapshotHistory.test.tsx new file mode 100644 index 00000000..93133910 --- /dev/null +++ b/src/components/common/__tests__/HolderSnapshotHistory.test.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import HolderSnapshotHistory from '@/components/common/HolderSnapshotHistory'; +import { HOLDER_SNAPSHOTS_STALE_TIME_MS } from '@/hooks/useHolderSnapshots'; +import type { HolderSnapshot } from '@/services/holderSnapshots.service'; + +// recharts needs real layout sizes; render the data it receives instead. +vi.mock('recharts', () => { + const Passthrough = ({ children }: { children?: React.ReactNode }) => ( +
{children}
+ ); + return { + ResponsiveContainer: Passthrough, + LineChart: ({ + data, + children, + }: { + data: unknown[]; + children?: React.ReactNode; + }) => ( +
+ {children} +
+ ), + Line: ({ dataKey }: { dataKey: string }) => ( +
+ ), + XAxis: ({ ticks }: { ticks?: number[] }) => ( +
+ ), + YAxis: () => null, + CartesianGrid: () => null, + Tooltip: () => null, + }; +}); + +const getHolderSnapshots = vi.fn(); +vi.mock('@/services/holderSnapshots.service', () => ({ + holderSnapshotsService: { + getHolderSnapshots: (...args: unknown[]) => getHolderSnapshots(...args), + }, +})); + +const NOW = Date.UTC(2026, 9, 17, 12, 0, 0); +const day = (n: number) => + new Date(NOW - n * 24 * 60 * 60 * 1000).toISOString(); + +function snap( + daysAgo: number, + over: Partial = {} +): HolderSnapshot { + return { + snapshotAt: day(daysAgo), + holderCount: 100, + topHolderPercent: 10, + top10SharePercent: 40, + giniCoefficient: 0.5, + ...over, + }; +} + +function renderViewer() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + + ); + return client; +} + +function chartPoints(testId: string): Array> { + const chart = screen.getByTestId(testId); + return JSON.parse( + chart + .querySelector('[data-testid="mock-line-chart"]')! + .getAttribute('data-points')! + ); +} + +describe('HolderSnapshotHistory (#1107)', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + getHolderSnapshots.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('plots holder count and top-10 share, oldest to newest', async () => { + getHolderSnapshots.mockResolvedValue([ + snap(1, { holderCount: 130, top10SharePercent: 35 }), + snap(5, { holderCount: 100, top10SharePercent: 45 }), + snap(3, { holderCount: 115, top10SharePercent: 40 }), + ]); + renderViewer(); + + await screen.findByTestId('holder-count-chart'); + const holders = chartPoints('holder-count-chart'); + expect(holders.map(p => p.holderCount)).toEqual([100, 115, 130]); + const shares = chartPoints('top10-concentration-chart'); + expect(shares.map(p => p.top10SharePercent)).toEqual([45, 40, 35]); + expect( + screen + .getByTestId('holder-count-chart') + .querySelector('[data-testid="mock-line"]') + ).toHaveAttribute('data-key', 'holderCount'); + }); + + it('requests the selected range and refetches when it changes', async () => { + getHolderSnapshots.mockResolvedValue([snap(2), snap(4), snap(6)]); + renderViewer(); + await screen.findByTestId('holder-count-chart'); + expect(getHolderSnapshots).toHaveBeenLastCalledWith('key-1', '30d'); + + fireEvent.click(screen.getByTestId('snapshot-range-7d')); + await waitFor(() => + expect(getHolderSnapshots).toHaveBeenLastCalledWith('key-1', '7d') + ); + expect(screen.getByTestId('snapshot-range-7d')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + + fireEvent.click(screen.getByTestId('snapshot-range-all')); + await waitFor(() => + expect(getHolderSnapshots).toHaveBeenLastCalledWith('key-1', 'all') + ); + }); + + it('limits the points to the selected window', async () => { + getHolderSnapshots.mockResolvedValue([ + snap(2, { holderCount: 1 }), + snap(20, { holderCount: 2 }), + snap(200, { holderCount: 3 }), + ]); + renderViewer(); + await screen.findByTestId('holder-count-chart'); + // 30d default: the 200-day-old snapshot is outside the window. + expect(chartPoints('holder-count-chart').map(p => p.holderCount)).toEqual( + [2, 1] + ); + + fireEvent.click(screen.getByTestId('snapshot-range-all')); + await waitFor(() => + expect(chartPoints('holder-count-chart')).toHaveLength(3) + ); + }); + + it('marks every snapshot date on the x-axis', async () => { + getHolderSnapshots.mockResolvedValue([ + snap(1), + snap(2), + snap(3), + snap(4), + ]); + renderViewer(); + await screen.findByTestId('holder-count-chart'); + const ticks = JSON.parse( + screen + .getByTestId('holder-count-chart') + .querySelector('[data-testid="mock-xaxis"]')! + .getAttribute('data-ticks')! + ) as number[]; + const points = chartPoints('holder-count-chart').map(p => p.ts); + expect(ticks).toEqual(points); + expect(points[0]).toBe(NOW - 4 * 24 * 60 * 60 * 1000); + }); + + it('shows the latest snapshot in the stats card', async () => { + getHolderSnapshots.mockResolvedValue([ + snap(10, { + holderCount: 90, + topHolderPercent: 20, + giniCoefficient: 0.8, + }), + snap(1, { + holderCount: 1250, + topHolderPercent: 7.5, + giniCoefficient: 0.42, + }), + ]); + renderViewer(); + await screen.findByTestId('holder-snapshot-stats'); + expect(screen.getByTestId('stat-total-holders')).toHaveTextContent( + '1,250' + ); + expect(screen.getByTestId('stat-top-holder')).toHaveTextContent('7.5%'); + expect(screen.getByTestId('stat-gini')).toHaveTextContent('0.42'); + }); + + it('renders for keys with fewer than 3 snapshots', async () => { + getHolderSnapshots.mockResolvedValue([snap(3), snap(1)]); + renderViewer(); + await screen.findByTestId('holder-count-chart'); + expect(screen.getByTestId('holder-snapshot-sparse')).toHaveTextContent( + /Only 2 snapshots/ + ); + expect(chartPoints('holder-count-chart')).toHaveLength(2); + expect(chartPoints('top10-concentration-chart')).toHaveLength(2); + }); + + it('renders a single snapshot', async () => { + getHolderSnapshots.mockResolvedValue([snap(1)]); + renderViewer(); + await screen.findByTestId('holder-count-chart'); + expect(screen.getByTestId('holder-snapshot-sparse')).toHaveTextContent( + /Only 1 snapshot so far/ + ); + expect(screen.getByTestId('stat-total-holders')).toHaveTextContent('100'); + }); + + it('shows an empty message when there are no snapshots', async () => { + getHolderSnapshots.mockResolvedValue([]); + renderViewer(); + expect( + await screen.findByTestId('holder-snapshot-empty') + ).toBeInTheDocument(); + expect(screen.queryByTestId('holder-count-chart')).toBeNull(); + }); + + it('shows an error message when loading fails', async () => { + getHolderSnapshots.mockRejectedValue(new Error('boom')); + renderViewer(); + expect( + await screen.findByTestId('holder-snapshot-error') + ).toBeInTheDocument(); + }); + + it('caches results for 5 minutes', () => { + expect(HOLDER_SNAPSHOTS_STALE_TIME_MS).toBe(5 * 60 * 1000); + }); +}); diff --git a/src/components/common/__tests__/TradeDialog.liquidityDepth.test.tsx b/src/components/common/__tests__/TradeDialog.liquidityDepth.test.tsx new file mode 100644 index 00000000..82c60176 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.liquidityDepth.test.tsx @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeDialog from '@/components/common/TradeDialog'; +import { computeMaxBuyWithinSlippage } from '@/utils/liquidityDepth.utils'; + +/** A supply where 1% slippage allows a handful of keys but 2% allows more. */ +function findSupply(): { supply: number; maxAt1: number; maxAt2: number } { + for (let supply = 100; supply < 1_000_000; supply += 100) { + const maxAt1 = computeMaxBuyWithinSlippage({ + currentSupply: supply, + slippageTolerancePercent: 1, + }); + const maxAt2 = computeMaxBuyWithinSlippage({ + currentSupply: supply, + slippageTolerancePercent: 2, + }); + if (maxAt1 >= 5 && maxAt1 <= 40 && maxAt2 > maxAt1 + 2) { + return { supply, maxAt1, maxAt2 }; + } + } + throw new Error('no suitable supply found for the default curve'); +} + +describe('TradeDialog liquidity depth indicator (#1108)', () => { + const { supply, maxAt1, maxAt2 } = findSupply(); + + beforeEach(() => { + window.localStorage.clear(); + }); + + function renderBuy() { + const onConfirm = vi.fn(); + render( + + ); + return { onConfirm }; + } + + function setAmount(value: string) { + fireEvent.change(screen.getByTestId('trade-dialog-amount'), { + target: { value }, + }); + } + + it('shows the max buy for the current slippage under the amount input', () => { + renderBuy(); + expect(screen.getByTestId('liquidity-depth-max')).toHaveTextContent( + `Max buy at 1% slippage: ${maxAt1} key` + ); + }); + + it('updates when the slippage tolerance changes', () => { + renderBuy(); + fireEvent.click(screen.getByTestId('slippage-preset-2')); + expect(screen.getByTestId('liquidity-depth-max')).toHaveTextContent( + `Max buy at 2% slippage: ${maxAt2} key` + ); + }); + + it('shows no warning for small amounts', () => { + renderBuy(); + setAmount('1'); + expect(screen.queryByTestId('liquidity-depth-warning')).toBeNull(); + }); + + it('warns when the amount approaches the limit but keeps Buy enabled', () => { + renderBuy(); + setAmount(String(maxAt1)); + const warning = screen.getByTestId('liquidity-depth-warning'); + expect(warning).toHaveAttribute('data-status', 'approaching'); + expect(screen.getByTestId('trade-dialog-confirm')).not.toBeDisabled(); + }); + + it('disables Buy and shows an alert when the amount exceeds the depth', () => { + const { onConfirm } = renderBuy(); + setAmount(String(maxAt1 + 1)); + const warning = screen.getByTestId('liquidity-depth-warning'); + expect(warning).toHaveAttribute('data-status', 'exceeded'); + expect(warning).toHaveAttribute('role', 'alert'); + const confirm = screen.getByTestId('trade-dialog-confirm'); + expect(confirm).toBeDisabled(); + fireEvent.click(confirm); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it('re-enables Buy after raising the slippage tolerance', () => { + renderBuy(); + setAmount(String(maxAt1 + 1)); + expect(screen.getByTestId('trade-dialog-confirm')).toBeDisabled(); + fireEvent.click(screen.getByTestId('slippage-preset-2')); + expect(screen.getByTestId('trade-dialog-confirm')).not.toBeDisabled(); + }); + + it('exposes an accessible tooltip explaining liquidity depth', () => { + renderBuy(); + const trigger = screen.getByRole('button', { + name: /what is liquidity depth/i, + }); + fireEvent.focus(trigger); + expect(screen.getByRole('tooltip')).toHaveTextContent( + /slippage tolerance/i + ); + }); + + it('is not shown on the sell side', () => { + render( + + ); + expect(screen.queryByTestId('liquidity-depth')).toBeNull(); + }); +}); diff --git a/src/components/common/__tests__/TradeHistoryLedger.test.tsx b/src/components/common/__tests__/TradeHistoryLedger.test.tsx new file mode 100644 index 00000000..bf5cb58f --- /dev/null +++ b/src/components/common/__tests__/TradeHistoryLedger.test.tsx @@ -0,0 +1,307 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import TradeHistoryLedger from '@/components/common/TradeHistoryLedger'; +import { + computeTradeAmounts, + filterTrades, + paginate, + tradesToCsv, +} from '@/utils/tradeLedger.utils'; +import type { Trade } from '@/services/tradeHistory.service'; + +const getTradeHistory = vi.fn(); +vi.mock('@/services/tradeHistory.service', () => ({ + tradeHistoryService: { + getTradeHistory: (...args: unknown[]) => getTradeHistory(...args), + }, +})); + +const downloadCsv = vi.fn(); +vi.mock('@/utils/transactionHistory.utils', () => ({ + downloadCsv: (...args: unknown[]) => downloadCsv(...args), +})); + +const T = (iso: string) => Date.parse(iso); + +function trade(i: number, over: Partial = {}): Trade { + return { + id: `t${i}`, + keyName: `Key ${i}`, + tradeType: i % 2 === 0 ? 'Buy' : 'Sell', + quantity: 2, + pricePerKey: 1.5, + timestamp: T('2026-10-01T12:00:00Z') + i * 60_000, + transactionHash: null, + feeXlm: 0.1, + ...over, + }; +} + +function renderLedger() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + render( + + + + ); +} + +function mockTrades(trades: Trade[], pageSize = 1000) { + getTradeHistory.mockImplementation( + async ({ cursor }: { cursor?: string | null }) => { + const start = cursor ? Number(cursor) : 0; + const slice = trades.slice(start, start + pageSize); + const next = + start + pageSize < trades.length ? String(start + pageSize) : null; + return { trades: slice, nextCursor: next }; + } + ); +} + +describe('tradeLedger utils (#1114)', () => { + it('computes total as subtotal + fee for buys and − fee for sells', () => { + expect( + computeTradeAmounts( + trade(0, { + tradeType: 'Buy', + quantity: 2, + pricePerKey: 1.5, + feeXlm: 0.1, + }) + ) + ).toEqual({ subtotal: 3, fee: 0.1, total: 3.1 }); + expect( + computeTradeAmounts( + trade(1, { + tradeType: 'Sell', + quantity: 2, + pricePerKey: 1.5, + feeXlm: 0.1, + }) + ) + ).toEqual({ subtotal: 3, fee: 0.1, total: 2.9 }); + expect(computeTradeAmounts(trade(2, { feeXlm: undefined })).fee).toBe(0); + }); + + it('filters by inclusive UTC date range and key name', () => { + const trades = [ + trade(1, { keyName: 'Alpha', timestamp: T('2026-10-01T00:00:00Z') }), + trade(2, { keyName: 'alpine', timestamp: T('2026-10-05T23:59:59Z') }), + trade(3, { keyName: 'Beta', timestamp: T('2026-10-06T00:00:00Z') }), + ]; + const byDate = filterTrades(trades, { + from: '2026-10-01', + to: '2026-10-05', + search: '', + }); + expect(byDate.map(t => t.id).sort()).toEqual(['t1', 't2']); + const byName = filterTrades(trades, { from: '', to: '', search: 'ALP' }); + expect(byName.map(t => t.id).sort()).toEqual(['t1', 't2']); + const both = filterTrades(trades, { + from: '2026-10-02', + to: '', + search: 'alp', + }); + expect(both.map(t => t.id)).toEqual(['t2']); + }); + + it('paginates and clamps the page', () => { + const items = Array.from({ length: 25 }, (_, i) => i); + expect(paginate(items, 1)).toHaveLength(10); + expect(paginate(items, 3)).toEqual([20, 21, 22, 23, 24]); + expect(paginate(items, 99)).toEqual([20, 21, 22, 23, 24]); + }); + + it('writes a CSV with headers, all rows, and escaped fields', () => { + const csv = tradesToCsv([ + trade(0, { keyName: 'Says "hi", ok' }), + trade(1, { keyName: '=SUM(A1)' }), + ]); + const lines = csv.split('\r\n'); + expect(lines[0]).toBe( + 'Date,Key,Direction,Amount,Price (XLM),Fee (XLM),Total (XLM)' + ); + expect(lines).toHaveLength(3); + expect(lines[1]).toContain('"Says ""hi"", ok",Buy,2,1.5,0.1,3.1'); + expect(lines[2]).toContain("'=SUM(A1),Sell,2,1.5,0.1,2.9"); + }); +}); + +describe('TradeHistoryLedger (#1114)', () => { + beforeEach(() => { + getTradeHistory.mockReset(); + downloadCsv.mockReset(); + }); + + it('shows the columns and values from the API', async () => { + mockTrades([trade(0, { keyName: 'Alice Key', tradeType: 'Buy' })]); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + + const headers = screen + .getAllByRole('columnheader') + .map(h => h.textContent); + expect(headers).toEqual([ + 'Date', + 'Key', + 'Direction', + 'Amount', + 'Price', + 'Fee', + 'Total', + ]); + const row = screen.getByTestId('trade-ledger-row'); + expect(within(row).getByText('Alice Key')).toBeInTheDocument(); + expect(within(row).getByText('Oct 1, 2026')).toBeInTheDocument(); + expect(within(row).getByText('2')).toBeInTheDocument(); + expect(within(row).getByText('1.50 XLM')).toBeInTheDocument(); + expect(within(row).getByText('0.10 XLM')).toBeInTheDocument(); + expect(within(row).getByText('3.10 XLM')).toBeInTheDocument(); + }); + + it('colour-codes direction chips: green Buy, red Sell', async () => { + mockTrades([ + trade(0, { tradeType: 'Buy' }), + trade(1, { tradeType: 'Sell' }), + ]); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + const chips = screen.getAllByTestId('direction-chip'); + const buy = chips.find(c => c.dataset.direction === 'Buy')!; + const sell = chips.find(c => c.dataset.direction === 'Sell')!; + expect(buy.className).toMatch(/emerald/); + expect(sell.className).toMatch(/rose/); + }); + + it('follows the cursor to load every page of history', async () => { + mockTrades( + Array.from({ length: 12 }, (_, i) => trade(i)), + 5 + ); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + expect(getTradeHistory).toHaveBeenCalledTimes(3); + expect(screen.getByTestId('trade-ledger-summary')).toHaveTextContent( + '12 trades' + ); + }); + + it('paginates across all pages', async () => { + mockTrades(Array.from({ length: 25 }, (_, i) => trade(i))); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + expect(screen.getAllByTestId('trade-ledger-row')).toHaveLength(10); + expect(screen.getByTestId('trade-ledger-summary')).toHaveTextContent( + 'Page 1 of 3' + ); + expect(screen.getByTestId('trade-ledger-prev')).toBeDisabled(); + + fireEvent.click(screen.getByTestId('trade-ledger-next')); + fireEvent.click(screen.getByTestId('trade-ledger-next')); + expect(screen.getAllByTestId('trade-ledger-row')).toHaveLength(5); + expect(screen.getByTestId('trade-ledger-summary')).toHaveTextContent( + 'Page 3 of 3' + ); + expect(screen.getByTestId('trade-ledger-next')).toBeDisabled(); + }); + + it('filters by key name and resets to the first page', async () => { + mockTrades(Array.from({ length: 25 }, (_, i) => trade(i))); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + fireEvent.click(screen.getByTestId('trade-ledger-next')); + + fireEvent.change(screen.getByTestId('trade-ledger-search'), { + target: { value: 'key 7' }, + }); + const rows = screen.getAllByTestId('trade-ledger-row'); + expect(rows).toHaveLength(1); + expect(within(rows[0]).getByText('Key 7')).toBeInTheDocument(); + expect(screen.getByTestId('trade-ledger-summary')).toHaveTextContent( + 'Page 1 of 1' + ); + }); + + it('filters by date range', async () => { + mockTrades([ + trade(1, { keyName: 'Early', timestamp: T('2026-09-01T10:00:00Z') }), + trade(2, { keyName: 'Late', timestamp: T('2026-10-10T10:00:00Z') }), + ]); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + fireEvent.change(screen.getByTestId('trade-ledger-from'), { + target: { value: '2026-10-01' }, + }); + const rows = screen.getAllByTestId('trade-ledger-row'); + expect(rows).toHaveLength(1); + expect(within(rows[0]).getByText('Late')).toBeInTheDocument(); + }); + + it('exports every matching row, not just the current page', async () => { + mockTrades(Array.from({ length: 25 }, (_, i) => trade(i))); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + expect(screen.getAllByTestId('trade-ledger-row')).toHaveLength(10); + + fireEvent.click(screen.getByTestId('trade-ledger-export')); + expect(downloadCsv).toHaveBeenCalledTimes(1); + const [filename, csv] = downloadCsv.mock.calls[0] as [string, string]; + expect(filename).toMatch( + /^accesslayer-trade-history-\d{4}-\d{2}-\d{2}\.csv$/ + ); + expect(csv.split('\r\n')).toHaveLength(26); // header + 25 rows + }); + + it('exports only the rows that match the filters', async () => { + mockTrades(Array.from({ length: 25 }, (_, i) => trade(i))); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + fireEvent.change(screen.getByTestId('trade-ledger-search'), { + target: { value: 'Key 1' }, // Key 1, Key 10..19 = 11 rows + }); + fireEvent.click(screen.getByTestId('trade-ledger-export')); + const csv = downloadCsv.mock.calls[0][1] as string; + expect(csv.split('\r\n')).toHaveLength(12); + }); + + it('shows an empty state when there is no trade history', async () => { + mockTrades([]); + renderLedger(); + expect( + await screen.findByTestId('trade-ledger-empty') + ).toBeInTheDocument(); + }); + + it('shows a no-matches state with a way to clear filters', async () => { + mockTrades([trade(0)]); + renderLedger(); + await screen.findByTestId('trade-ledger-table'); + fireEvent.change(screen.getByTestId('trade-ledger-search'), { + target: { value: 'zzz' }, + }); + expect(screen.getByTestId('trade-ledger-no-matches')).toBeInTheDocument(); + expect(screen.getByTestId('trade-ledger-export')).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Clear filters' })); + await waitFor(() => + expect(screen.getAllByTestId('trade-ledger-row')).toHaveLength(1) + ); + }); + + it('shows an error state when loading fails', async () => { + getTradeHistory.mockRejectedValue(new Error('nope')); + renderLedger(); + expect( + await screen.findByTestId('trade-ledger-error') + ).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/useAllTradeHistory.ts b/src/hooks/useAllTradeHistory.ts new file mode 100644 index 00000000..cde9eb8e --- /dev/null +++ b/src/hooks/useAllTradeHistory.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + tradeHistoryService, + type Trade, +} from '@/services/tradeHistory.service'; + +/** Safety cap so a runaway cursor can never loop forever. */ +export const MAX_TRADE_HISTORY_PAGES = 200; + +/** + * Loads every page of a wallet's trade history (following the cursor) so the + * portfolio table can filter, paginate and export all matching rows (#1114). + */ +export async function fetchAllTrades(wallet: string): Promise { + const trades: Trade[] = []; + const seen = new Set(); + let cursor: string | null = null; + + for (let page = 0; page < MAX_TRADE_HISTORY_PAGES; page++) { + const result = await tradeHistoryService.getTradeHistory({ + wallet, + cursor, + }); + for (const trade of result.trades) { + if (seen.has(trade.id)) continue; + seen.add(trade.id); + trades.push(trade); + } + if (!result.nextCursor || result.nextCursor === cursor) break; + cursor = result.nextCursor; + } + + return trades; +} + +export function useAllTradeHistory(wallet: string | undefined) { + return useQuery({ + queryKey: queryKeys.wallet.allTradeHistory(wallet ?? ''), + queryFn: () => fetchAllTrades(wallet!), + enabled: Boolean(wallet), + staleTime: 15_000, + }); +} diff --git a/src/hooks/useHolderSnapshots.ts b/src/hooks/useHolderSnapshots.ts new file mode 100644 index 00000000..571b8049 --- /dev/null +++ b/src/hooks/useHolderSnapshots.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + holderSnapshotsService, + type HolderSnapshot, + type HolderSnapshotRange, +} from '@/services/holderSnapshots.service'; + +/** Snapshots change slowly, so they are cached for 5 minutes (#1107). */ +export const HOLDER_SNAPSHOTS_STALE_TIME_MS = 5 * 60 * 1000; + +export function useHolderSnapshots( + keyId: string | undefined, + range: HolderSnapshotRange +) { + return useQuery({ + queryKey: queryKeys.creators.holderSnapshots(keyId ?? '', range), + queryFn: () => holderSnapshotsService.getHolderSnapshots(keyId!, range), + enabled: Boolean(keyId), + staleTime: HOLDER_SNAPSHOTS_STALE_TIME_MS, + retry: false, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 16fac302..a37010e8 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -29,6 +29,8 @@ export const queryKeys = { ['creators', creatorId, 'priceHistory', interval] as const, holders: (creatorId: string) => ['creators', creatorId, 'holders'] as const, + holderSnapshots: (creatorId: string, range: string) => + ['creators', creatorId, 'holder-snapshots', range] as const, activity: (creatorId: string) => ['creators', creatorId, 'activity'] as const, twap: (creatorId: string, window = '24h') => @@ -79,6 +81,8 @@ export const queryKeys = { activity: (address: string) => ['wallet', address, 'activity'] as const, tradeHistory: (address: string) => ['wallet', address, 'tradeHistory'] as const, + allTradeHistory: (address: string) => + ['wallet', address, 'allTradeHistory'] as const, vestingPositions: (address: string) => ['wallet', address, 'vestingPositions'] as const, stakingPositions: (address: string) => diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 7740eaf9..fa9bfe9a 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -17,6 +17,8 @@ import KeySimulationTool from '@/components/common/KeySimulationTool'; import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown'; import KeyHolderList from '@/components/common/KeyHolderList'; import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; +import HolderSnapshotHistory from '@/components/common/HolderSnapshotHistory'; +import GraduationCelebration from '@/components/common/GraduationCelebration'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; import StakingVaultPanel from '@/components/common/StakingVaultPanel'; import DeprecationNotice from '@/components/common/DeprecationNotice'; @@ -800,6 +802,16 @@ function CreatorDetailPageContent() { isLoading={isLoading} /> + + 0 ? holders.length : null} + walletAddress={connectedWalletAddress} + /> + + {auctionPhase !== 'active' && ( <>
+
+

+ Holder Snapshot History +

+ + + + +
+

diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index ac65257e..0ec481a7 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -15,7 +15,7 @@ import PortfolioSummaryHeader from '@/components/common/PortfolioSummaryHeader'; import HeldKeysGrid from '@/components/common/HeldKeysGrid'; import StakingPositionsList from '@/components/common/StakingPositionsList'; import LiquidityPositionsSection from '@/components/common/LiquidityPositionsSection'; -import TradeHistoryTable from '@/components/common/TradeHistoryTable'; +import TradeHistoryLedger from '@/components/common/TradeHistoryLedger'; import AtomicSwapHistory from '@/components/common/AtomicSwapHistory'; import ProtocolRevenueClaim from '@/components/common/ProtocolRevenueClaim'; import ProtocolRevenueDistributionTable from '@/components/common/ProtocolRevenueDistributionTable'; @@ -521,10 +521,7 @@ export default function ProfilePage() {

{' '} - +
diff --git a/src/pages/__tests__/ProfilePage.integration.test.tsx b/src/pages/__tests__/ProfilePage.integration.test.tsx index 77549112..5813b4e1 100644 --- a/src/pages/__tests__/ProfilePage.integration.test.tsx +++ b/src/pages/__tests__/ProfilePage.integration.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, screen } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router'; import ProfilePage from '@/pages/ProfilePage'; import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; @@ -9,12 +9,14 @@ import type { Trade } from '@/services/tradeHistory.service'; const { mockUseWalletHoldings, mockUseTradeHistory, + mockUseAllTradeHistory, mockUseCreatorPrices, mockUseStakingPositions, mockUseAccount, } = vi.hoisted(() => ({ mockUseWalletHoldings: vi.fn(), mockUseTradeHistory: vi.fn(), + mockUseAllTradeHistory: vi.fn(), mockUseCreatorPrices: vi.fn(), mockUseStakingPositions: vi.fn(), mockUseAccount: vi.fn(), @@ -30,6 +32,10 @@ vi.mock('@/hooks/useWallet', () => ({ }), })); +vi.mock('@/hooks/useAllTradeHistory', () => ({ + useAllTradeHistory: (...args: unknown[]) => mockUseAllTradeHistory(...args), +})); + vi.mock('@/hooks/useCreatorPrices', () => ({ useCreatorPrices: (...args: unknown[]) => mockUseCreatorPrices(...args), })); @@ -151,15 +157,10 @@ function seedOwnerView(connectedAddress?: string) { isLoading: false, isError: false, }); -} - -function simulateIntersection(isIntersecting: boolean) { - const obs = observers[observers.length - 1]; - act(() => { - obs.callback( - [{ isIntersecting } as IntersectionObserverEntry], - {} as IntersectionObserver - ); + mockUseAllTradeHistory.mockReturnValue({ + data: [trade], + isLoading: false, + isError: false, }); } @@ -187,6 +188,7 @@ describe('ProfilePage — user profile page (#921)', () => { mockUseWalletHoldings.mockReset(); mockUseTradeHistory.mockReset(); + mockUseAllTradeHistory.mockReset(); mockUseCreatorPrices.mockReset(); mockUseStakingPositions.mockReset(); mockUseAccount.mockReset(); @@ -270,26 +272,15 @@ describe('ProfilePage — user profile page (#921)', () => { }); }); - it('loads more trade history via infinite scroll on the trade tab', async () => { + it('shows the paginated trade history table with CSV export on the trade tab (#1114)', () => { seedOwnerView(); - mockUseTradeHistory.mockReturnValue({ - data: { pages: [{ trades: [trade], nextCursor: 'cursor-2' }] }, - fetchNextPage: vi.fn(), - hasNextPage: true, - isFetchingNextPage: false, - isLoading: false, - isError: false, - }); renderProfile('/profile?tab=trade-history'); - const { fetchNextPage } = mockUseTradeHistory.mock.results[0].value; - - expect( - screen.getByTestId('trade-history-load-more-sentinel') - ).toBeInTheDocument(); - simulateIntersection(true); - expect(fetchNextPage).toHaveBeenCalledTimes(1); + expect(mockUseAllTradeHistory).toHaveBeenCalledWith(DEMO_WALLET); + expect(screen.getByTestId('trade-ledger-table')).toBeInTheDocument(); + expect(screen.getByText('Alpha Key')).toBeInTheDocument(); + expect(screen.getByTestId('trade-ledger-export')).toBeInTheDocument(); }); it('renders staking positions with lock status and claimable rewards on the staking tab', () => { diff --git a/src/services/holderSnapshots.service.ts b/src/services/holderSnapshots.service.ts new file mode 100644 index 00000000..316ff9aa --- /dev/null +++ b/src/services/holderSnapshots.service.ts @@ -0,0 +1,42 @@ +import { BaseApiService, type APIResponse } from './api.service'; + +/** Time ranges offered by the holder snapshot history viewer (#1107). */ +export type HolderSnapshotRange = '7d' | '30d' | 'all'; + +/** One point-in-time snapshot of a key's holder distribution. */ +export interface HolderSnapshot { + /** ISO timestamp of when the snapshot was taken. */ + snapshotAt: string; + /** Total number of holders at that time. */ + holderCount: number; + /** Share of total supply held by the single largest holder, in percent. */ + topHolderPercent: number; + /** Share of total supply held by the top 10 holders, in percent. */ + top10SharePercent: number; + /** Gini coefficient of the holder distribution, 0 (equal) to 1 (one holder). */ + giniCoefficient: number; +} + +export interface HolderSnapshotsResponse { + snapshots: HolderSnapshot[]; +} + +class HolderSnapshotsService extends BaseApiService { + // GET /keys/:keyId/holder-snapshots?range=7d|30d|all + async getHolderSnapshots( + keyId: string, + range: HolderSnapshotRange + ): Promise { + try { + const response = await this.api.get< + APIResponse + >(`/keys/${keyId}/holder-snapshots`, { params: { range } }); + + return response.data.data?.snapshots ?? []; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const holderSnapshotsService = new HolderSnapshotsService(); diff --git a/src/services/tradeHistory.service.ts b/src/services/tradeHistory.service.ts index 3be3da9b..8a722a63 100644 --- a/src/services/tradeHistory.service.ts +++ b/src/services/tradeHistory.service.ts @@ -28,6 +28,8 @@ export interface Trade { * trade history row. */ transactionHash: string | null; + /** Fee paid (buy) or deducted (sell) for this trade, in XLM, when reported. */ + feeXlm?: number | null; } /** Cursor-paginated response envelope for the trade history endpoint. */ diff --git a/src/utils/__tests__/liquidityDepth.utils.test.ts b/src/utils/__tests__/liquidityDepth.utils.test.ts new file mode 100644 index 00000000..8ff73569 --- /dev/null +++ b/src/utils/__tests__/liquidityDepth.utils.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { + DEPTH_WARNING_RATIO, + classifyLiquidityDepth, + computeMaxBuyWithinSlippage, +} from '@/utils/liquidityDepth.utils'; +import { calculateTradePriceImpact } from '@/utils/priceImpact.utils'; + +const impact = (quantity: number, currentSupply: number) => + calculateTradePriceImpact({ side: 'buy', quantity, currentSupply }); + +describe('computeMaxBuyWithinSlippage (#1108)', () => { + it('returns the largest quantity whose impact stays within tolerance', () => { + const supply = 1_000; + const tolerance = 1; + const max = computeMaxBuyWithinSlippage({ + currentSupply: supply, + slippageTolerancePercent: tolerance, + }); + + expect(max).toBeGreaterThan(0); + expect(impact(max, supply)).toBeLessThanOrEqual(tolerance); + if (max < 100) { + expect(impact(max + 1, supply)).toBeGreaterThan(tolerance); + } + }); + + it('grows when the slippage tolerance is raised', () => { + const supply = 1_000; + const low = computeMaxBuyWithinSlippage({ + currentSupply: supply, + slippageTolerancePercent: 0.5, + }); + const high = computeMaxBuyWithinSlippage({ + currentSupply: supply, + slippageTolerancePercent: 2, + }); + expect(high).toBeGreaterThan(low); + }); + + it('is limited by the per-trade maximum', () => { + expect( + computeMaxBuyWithinSlippage({ + currentSupply: 1_000_000, + slippageTolerancePercent: 50, + upperBound: 25, + }) + ).toBe(25); + }); + + it('returns 0 when even one key exceeds the tolerance', () => { + expect( + computeMaxBuyWithinSlippage({ + currentSupply: 1, + slippageTolerancePercent: 0, + }) + ).toBe(0); + }); + + it('returns 0 for invalid input', () => { + expect( + computeMaxBuyWithinSlippage({ + currentSupply: Number.NaN, + slippageTolerancePercent: 1, + }) + ).toBe(0); + expect( + computeMaxBuyWithinSlippage({ + currentSupply: 100, + slippageTolerancePercent: -1, + }) + ).toBe(0); + }); +}); + +describe('classifyLiquidityDepth (#1108)', () => { + it('is ok well under the limit', () => { + expect(classifyLiquidityDepth(10, 100)).toBe('ok'); + }); + + it('warns from the warning ratio up to and including the limit', () => { + expect(classifyLiquidityDepth(100 * DEPTH_WARNING_RATIO, 100)).toBe( + 'approaching' + ); + expect(classifyLiquidityDepth(100, 100)).toBe('approaching'); + }); + + it('flags amounts above the limit as exceeded', () => { + expect(classifyLiquidityDepth(101, 100)).toBe('exceeded'); + expect(classifyLiquidityDepth(1, 0)).toBe('exceeded'); + }); + + it('does not warn for empty or invalid amounts', () => { + expect(classifyLiquidityDepth(Number.NaN, 100)).toBe('ok'); + expect(classifyLiquidityDepth(0, 100)).toBe('ok'); + }); +}); diff --git a/src/utils/graduation.utils.ts b/src/utils/graduation.utils.ts new file mode 100644 index 00000000..0ab4b1e4 --- /dev/null +++ b/src/utils/graduation.utils.ts @@ -0,0 +1,120 @@ +import type { + CurveMilestone, + GraduatedCurveConfig, +} from '@/services/course.service'; +import { buildCreatorKeyReferralLink } from '@/utils/referral.utils'; +import { buildTwitterIntentUrl } from '@/utils/shareTwitter.utils'; + +/** sessionStorage prefix; one entry per key that already played its animation. */ +export const GRADUATION_SESSION_PREFIX = 'accesslayer:graduation-celebrated:'; + +/** The last (highest-supply) milestone of the graduated curve, if configured. */ +export function getFinalMilestone( + config: GraduatedCurveConfig | null | undefined +): CurveMilestone | null { + const milestones = (config?.milestones ?? []).filter( + m => Number.isFinite(m?.supplyThreshold) && m.supplyThreshold > 0 + ); + if (milestones.length === 0) return null; + return milestones.reduce((final, m) => + m.supplyThreshold > final.supplyThreshold ? m : final + ); +} + +/** + * A key has graduated when its supply has reached the final milestone of its + * graduated curve (the GraduationReached condition, #1110). + */ +export function hasKeyGraduated( + config: GraduatedCurveConfig | null | undefined, + currentSupply: number | null | undefined +): boolean { + const final = getFinalMilestone(config); + if (!final) return false; + if (currentSupply == null || !Number.isFinite(currentSupply)) return false; + return currentSupply >= final.supplyThreshold; +} + +export interface GraduationStats { + /** Supply at which the key graduated (the final milestone threshold). */ + finalSupply: number; + /** Simulated per-key price at the final milestone, in XLM. */ + graduationPriceXlm: number | null; + /** Number of curve milestones the key passed. */ + milestonesReached: number; + holderCount: number | null; +} + +export function buildGraduationStats( + config: GraduatedCurveConfig | null | undefined, + holderCount?: number | null +): GraduationStats | null { + const final = getFinalMilestone(config); + if (!final) return null; + const price = Number(final.simulatedPrice); + return { + finalSupply: final.supplyThreshold, + graduationPriceXlm: Number.isFinite(price) && price > 0 ? price : null, + milestonesReached: (config?.milestones ?? []).length, + holderCount: + holderCount != null && Number.isFinite(holderCount) + ? holderCount + : null, + }; +} + +export interface GraduationShareInput { + keyName: string; + stats: GraduationStats; + /** The link people land on; use {@link buildGraduationReferralLink}. */ + link: string; +} + +export function buildGraduationTweetText({ + keyName, + stats, + link, +}: GraduationShareInput): string { + const parts = [`${keyName} just graduated on AccessLayer`]; + parts.push(`${stats.finalSupply.toLocaleString('en-US')} keys minted`); + if (stats.holderCount != null) { + parts.push(`${stats.holderCount.toLocaleString('en-US')} holders`); + } + return `🎓 ${parts.join(' — ')}. Check it out: ${link}`; +} + +export function buildGraduationTweetUrl(input: GraduationShareInput): string { + return buildTwitterIntentUrl(buildGraduationTweetText(input)); +} + +/** The key page URL, carrying the sharer's referral code when a wallet is known. */ +export function buildGraduationReferralLink( + keyId: string, + wallet?: string | null +): string { + return buildCreatorKeyReferralLink({ creatorId: keyId, wallet }); +} + +/** True once this key's animation has played in the current browser session. */ +export function hasCelebratedThisSession(keyId: string): boolean { + try { + return ( + window.sessionStorage.getItem( + `${GRADUATION_SESSION_PREFIX}${keyId}` + ) === '1' + ); + } catch { + return false; + } +} + +export function markCelebratedThisSession(keyId: string): void { + try { + window.sessionStorage.setItem( + `${GRADUATION_SESSION_PREFIX}${keyId}`, + '1' + ); + } catch { + // Storage unavailable: the animation may replay on remount, which is harmless. + } +} diff --git a/src/utils/holderSnapshots.utils.ts b/src/utils/holderSnapshots.utils.ts new file mode 100644 index 00000000..2df881e5 --- /dev/null +++ b/src/utils/holderSnapshots.utils.ts @@ -0,0 +1,88 @@ +import type { + HolderSnapshot, + HolderSnapshotRange, +} from '@/services/holderSnapshots.service'; + +export const HOLDER_SNAPSHOT_RANGES: ReadonlyArray<{ + value: HolderSnapshotRange; + label: string; +}> = [ + { value: '7d', label: '7d' }, + { value: '30d', label: '30d' }, + { value: 'all', label: 'All time' }, +]; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface HolderSnapshotPoint { + /** Snapshot time as epoch milliseconds (numeric x-axis value). */ + ts: number; + holderCount: number; + top10SharePercent: number; + topHolderPercent: number; + giniCoefficient: number; +} + +function rangeWindowMs(range: HolderSnapshotRange): number | null { + if (range === '7d') return 7 * DAY_MS; + if (range === '30d') return 30 * DAY_MS; + return null; +} + +/** + * Converts API snapshots to chart points: drops malformed entries, keeps only + * those inside the selected range, and sorts oldest to newest. + */ +export function buildSnapshotPoints( + snapshots: HolderSnapshot[] | undefined, + range: HolderSnapshotRange, + now: number = Date.now() +): HolderSnapshotPoint[] { + const windowMs = rangeWindowMs(range); + const cutoff = windowMs == null ? -Infinity : now - windowMs; + + return (snapshots ?? []) + .map(snapshot => ({ + ts: Date.parse(snapshot.snapshotAt), + holderCount: snapshot.holderCount, + top10SharePercent: snapshot.top10SharePercent, + topHolderPercent: snapshot.topHolderPercent, + giniCoefficient: snapshot.giniCoefficient, + })) + .filter( + point => + Number.isFinite(point.ts) && + Number.isFinite(point.holderCount) && + Number.isFinite(point.top10SharePercent) && + point.ts >= cutoff + ) + .sort((a, b) => a.ts - b.ts); +} + +/** The most recent snapshot, or null when there is none. */ +export function latestSnapshot( + points: HolderSnapshotPoint[] +): HolderSnapshotPoint | null { + return points.length > 0 ? points[points.length - 1] : null; +} + +/** Fewer than this many snapshots cannot draw a meaningful line. */ +export const MIN_SNAPSHOTS_FOR_TREND = 3; + +export function hasEnoughSnapshotsForTrend( + points: HolderSnapshotPoint[] +): boolean { + return points.length >= MIN_SNAPSHOTS_FOR_TREND; +} + +export function formatSnapshotDate(ts: number): string { + return new Date(ts).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); +} + +export function formatGini(value: number): string { + return Number.isFinite(value) ? value.toFixed(2) : '—'; +} diff --git a/src/utils/liquidityDepth.utils.ts b/src/utils/liquidityDepth.utils.ts new file mode 100644 index 00000000..d00d1da8 --- /dev/null +++ b/src/utils/liquidityDepth.utils.ts @@ -0,0 +1,83 @@ +/** + * Liquidity depth helpers for the buy form (#1108). + * + * "Depth" is the largest number of keys a user can buy in one trade before the + * bonding-curve price impact exceeds their slippage tolerance. Buying more + * than that would move the price past the tolerance and the trade would be + * rejected on-chain, so the buy form warns about it and blocks it. + */ +import { calculateTradePriceImpact } from '@/utils/priceImpact.utils'; +import { BUY_QUANTITY_BOUNDS } from '@/constants/fees'; + +/** Share of the max depth at which the "approaching limit" warning starts. */ +export const DEPTH_WARNING_RATIO = 0.8; + +export type LiquidityDepthStatus = 'ok' | 'approaching' | 'exceeded'; + +export interface MaxBuyWithinSlippageOptions { + /** Current key supply. */ + currentSupply: number; + /** User's slippage tolerance in percent (e.g. 1 = 1%). */ + slippageTolerancePercent: number; + /** Upper bound for the search; defaults to the per-trade buy maximum. */ + upperBound?: number; +} + +/** + * Largest whole number of keys whose price impact stays within the slippage + * tolerance. Price impact grows monotonically with quantity on the bonding + * curve, so a binary search finds it. Returns 0 when even one key is too much. + */ +export function computeMaxBuyWithinSlippage({ + currentSupply, + slippageTolerancePercent, + upperBound = BUY_QUANTITY_BOUNDS.MAX_QTY, +}: MaxBuyWithinSlippageOptions): number { + if ( + !Number.isFinite(currentSupply) || + currentSupply < 0 || + !Number.isFinite(slippageTolerancePercent) || + slippageTolerancePercent < 0 || + !Number.isFinite(upperBound) || + upperBound < 1 + ) { + return 0; + } + + const withinTolerance = (quantity: number): boolean => + calculateTradePriceImpact({ + side: 'buy', + quantity, + currentSupply, + }) <= slippageTolerancePercent; + + if (!withinTolerance(1)) return 0; + const cap = Math.floor(upperBound); + if (withinTolerance(cap)) return cap; + + let low = 1; // known to fit + let high = cap; // known not to fit + while (high - low > 1) { + const mid = Math.floor((low + high) / 2); + if (withinTolerance(mid)) { + low = mid; + } else { + high = mid; + } + } + return low; +} + +/** + * Classifies the entered amount against the max depth. Amounts that are not a + * positive number are `ok` (there is nothing to warn about yet). + */ +export function classifyLiquidityDepth( + amount: number, + maxDepth: number +): LiquidityDepthStatus { + if (!Number.isFinite(amount) || amount <= 0) return 'ok'; + if (amount > maxDepth) return 'exceeded'; + if (amount >= maxDepth * DEPTH_WARNING_RATIO) return 'approaching'; + return 'ok'; +} diff --git a/src/utils/tradeLedger.utils.ts b/src/utils/tradeLedger.utils.ts new file mode 100644 index 00000000..1c3288e7 --- /dev/null +++ b/src/utils/tradeLedger.utils.ts @@ -0,0 +1,128 @@ +/** + * Helpers for the portfolio trade history table and its CSV export (#1114). + */ +import type { Trade } from '@/services/tradeHistory.service'; + +export const TRADE_LEDGER_PAGE_SIZE = 10; + +export interface TradeLedgerFilters { + /** Inclusive start date, `YYYY-MM-DD` (UTC), or empty for no lower bound. */ + from: string; + /** Inclusive end date, `YYYY-MM-DD` (UTC), or empty for no upper bound. */ + to: string; + /** Case-insensitive substring of the key name. */ + search: string; +} + +export const EMPTY_TRADE_LEDGER_FILTERS: TradeLedgerFilters = { + from: '', + to: '', + search: '', +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function parseDayStart(day: string): number | null { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return null; + const ms = Date.parse(`${day}T00:00:00.000Z`); + return Number.isFinite(ms) ? ms : null; +} + +export interface TradeAmounts { + /** quantity × price per key. */ + subtotal: number; + fee: number; + /** Buy: subtotal + fee paid. Sell: subtotal − fee, what was received. */ + total: number; +} + +export function computeTradeAmounts(trade: Trade): TradeAmounts { + const subtotal = trade.quantity * trade.pricePerKey; + const fee = + trade.feeXlm != null && Number.isFinite(trade.feeXlm) ? trade.feeXlm : 0; + return { + subtotal, + fee, + total: trade.tradeType === 'Buy' ? subtotal + fee : subtotal - fee, + }; +} + +/** Applies the date range and key-name search; newest trades first. */ +export function filterTrades( + trades: Trade[], + filters: TradeLedgerFilters +): Trade[] { + const fromMs = parseDayStart(filters.from); + const toStart = parseDayStart(filters.to); + const toMs = toStart == null ? null : toStart + DAY_MS - 1; + const query = filters.search.trim().toLowerCase(); + + return trades + .filter(trade => { + if (fromMs != null && trade.timestamp < fromMs) return false; + if (toMs != null && trade.timestamp > toMs) return false; + if (query && !trade.keyName.toLowerCase().includes(query)) + return false; + return true; + }) + .sort((a, b) => b.timestamp - a.timestamp); +} + +export function pageCount(total: number, pageSize = TRADE_LEDGER_PAGE_SIZE) { + return Math.max(1, Math.ceil(total / pageSize)); +} + +export function paginate( + items: T[], + page: number, + pageSize = TRADE_LEDGER_PAGE_SIZE +): T[] { + const safePage = Math.min( + Math.max(1, page), + pageCount(items.length, pageSize) + ); + const start = (safePage - 1) * pageSize; + return items.slice(start, start + pageSize); +} + +export const TRADE_CSV_HEADERS = [ + 'Date', + 'Key', + 'Direction', + 'Amount', + 'Price (XLM)', + 'Fee (XLM)', + 'Total (XLM)', +] as const; + +/** + * Quotes a field when needed and neutralises spreadsheet formula injection, + * since key names are creator-controlled text. + */ +function escapeCsvField(value: string): string { + const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; +} + +/** CSV for every trade passed in; callers pass all matching rows, not one page. */ +export function tradesToCsv(trades: Trade[]): string { + const rows = trades.map(trade => { + const { fee, total } = computeTradeAmounts(trade); + return [ + new Date(trade.timestamp).toISOString(), + trade.keyName, + trade.tradeType, + String(trade.quantity), + String(trade.pricePerKey), + String(fee), + String(total), + ].map(escapeCsvField); + }); + return [[...TRADE_CSV_HEADERS], ...rows] + .map(row => row.join(',')) + .join('\r\n'); +} + +export function buildTradeCsvFilename(now: number = Date.now()): string { + return `accesslayer-trade-history-${new Date(now).toISOString().slice(0, 10)}.csv`; +}