From 34d629de9e14f1aea46549f78139fdccb5daef20 Mon Sep 17 00:00:00 2001 From: novi Date: Tue, 20 Jan 2026 11:23:28 +0200 Subject: [PATCH 1/5] setup leaderboard --- src/app/leaderboard/layout.tsx | 11 + src/app/leaderboard/loading.tsx | 7 + src/app/leaderboard/page.tsx | 30 ++ .../leaderboard/activity-sparkline.tsx | 141 ++++++ src/components/leaderboard/address-cell.tsx | 102 +++++ src/components/leaderboard/index.ts | 5 + .../leaderboard/leaderboard-stats.tsx | 130 ++++++ .../leaderboard/leaderboard-table.tsx | 404 ++++++++++++++++++ src/components/leaderboard/medal-rank.tsx | 76 ++++ src/layouts/config-nav-dashboard.tsx | 4 + src/pages/api/leaderboard-sparklines.ts | 103 +++++ src/pages/api/leaderboard.ts | 188 ++++++++ src/routes/paths.ts | 6 + src/utils/axios.ts | 2 + 14 files changed, 1209 insertions(+) create mode 100644 src/app/leaderboard/layout.tsx create mode 100644 src/app/leaderboard/loading.tsx create mode 100644 src/app/leaderboard/page.tsx create mode 100644 src/components/leaderboard/activity-sparkline.tsx create mode 100644 src/components/leaderboard/address-cell.tsx create mode 100644 src/components/leaderboard/index.ts create mode 100644 src/components/leaderboard/leaderboard-stats.tsx create mode 100644 src/components/leaderboard/leaderboard-table.tsx create mode 100644 src/components/leaderboard/medal-rank.tsx create mode 100644 src/pages/api/leaderboard-sparklines.ts create mode 100644 src/pages/api/leaderboard.ts diff --git a/src/app/leaderboard/layout.tsx b/src/app/leaderboard/layout.tsx new file mode 100644 index 0000000..74e0aaa --- /dev/null +++ b/src/app/leaderboard/layout.tsx @@ -0,0 +1,11 @@ +import { DashboardLayout } from 'src/layouts/dashboard' + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode +} + +export default function Layout({ children }: Props) { + return {children} +} diff --git a/src/app/leaderboard/loading.tsx b/src/app/leaderboard/loading.tsx new file mode 100644 index 0000000..eb8c308 --- /dev/null +++ b/src/app/leaderboard/loading.tsx @@ -0,0 +1,7 @@ +import { LoadingScreen } from 'src/components/loading-screen' + +// ---------------------------------------------------------------------- + +export default function Loading() { + return +} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx new file mode 100644 index 0000000..17cc5d7 --- /dev/null +++ b/src/app/leaderboard/page.tsx @@ -0,0 +1,30 @@ +'use client' + +import { Box, Typography } from '@mui/material' +import { LeaderboardTable } from 'src/components/leaderboard' +import { DashboardContent } from 'src/layouts/dashboard' +import { Iconify } from 'src/components/iconify' + +export default function LeaderboardPage() { + return ( + + {/* Page Header */} + + + + Leaderboard + + + Top bridge users ranked by volume and transaction activity + + + + {/* Leaderboard Table */} + + + ) +} diff --git a/src/components/leaderboard/activity-sparkline.tsx b/src/components/leaderboard/activity-sparkline.tsx new file mode 100644 index 0000000..b54a1b7 --- /dev/null +++ b/src/components/leaderboard/activity-sparkline.tsx @@ -0,0 +1,141 @@ +import { Box } from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' +import { useMemo } from 'react' + +interface ActivitySparklineProps { + data: number[] + width?: number + height?: number + color?: string +} + +export function ActivitySparkline({ + data, + width = 80, + height = 30, + color, +}: ActivitySparklineProps) { + const theme = useTheme() + const strokeColor = color || theme.palette.primary.main + + const { path, maxValue, hasActivity } = useMemo(() => { + if (!data || data.length === 0) { + return { path: '', maxValue: 0, hasActivity: false } + } + + const max = Math.max(...data, 1) // Ensure at least 1 to avoid division by zero + const hasAct = data.some(v => v > 0) + + const padding = 2 + const chartWidth = width - padding * 2 + const chartHeight = height - padding * 2 + + const points = data.map((value, index) => { + const x = padding + (index / (data.length - 1 || 1)) * chartWidth + const y = padding + chartHeight - (value / max) * chartHeight + return { x, y } + }) + + // Create SVG path + const pathData = points + .map((point, index) => { + if (index === 0) return `M ${point.x} ${point.y}` + return `L ${point.x} ${point.y}` + }) + .join(' ') + + return { path: pathData, maxValue: max, hasActivity: hasAct } + }, [data, width, height]) + + // Create area path (for fill) - must be called before any conditional returns + const areaPath = useMemo(() => { + if (!data || data.length === 0 || !path) return '' + + const padding = 2 + const chartWidth = width - padding * 2 + const chartHeight = height - padding * 2 + const max = Math.max(...data, 1) + + const points = data.map((value, index) => { + const x = padding + (index / (data.length - 1 || 1)) * chartWidth + const y = padding + chartHeight - (value / max) * chartHeight + return { x, y } + }) + + const bottomY = padding + chartHeight + const firstX = points[0]?.x || padding + const lastX = points[points.length - 1]?.x || width - padding + + return `${path} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z` + }, [data, width, height, path]) + + // No activity - show placeholder (after all hooks) + if (!hasActivity) { + return ( + + + + ) + } + + return ( + + + {/* Gradient definition */} + + + + + + + + {/* Area fill */} + + + {/* Line */} + + + + ) +} diff --git a/src/components/leaderboard/address-cell.tsx b/src/components/leaderboard/address-cell.tsx new file mode 100644 index 0000000..37bc890 --- /dev/null +++ b/src/components/leaderboard/address-cell.tsx @@ -0,0 +1,102 @@ +import { Box, IconButton, Tooltip, Typography } from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' +import { useState } from 'react' +import { Iconify } from 'src/components/iconify' +import { truncateAddress } from 'src/config/helper' + +interface AddressCellProps { + address: string + addressType: 'sui' | 'eth' + onClick?: () => void +} + +export function AddressCell({ address, addressType, onClick }: AddressCellProps) { + const theme = useTheme() + const [copied, setCopied] = useState(false) + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation() + try { + // Format address with proper prefix + const fullAddress = addressType === 'eth' ? `0x${address}` : `0x${address}` + await navigator.clipboard.writeText(fullAddress) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch (err) { + console.error('Failed to copy address:', err) + } + } + + const chainColor = addressType === 'sui' ? '#4DA2FF' : '#627EEA' + const chainIcon = + addressType === 'sui' ? '/assets/icons/brands/sui.svg' : '/assets/icons/brands/eth.svg' + + return ( + + {/* Chain icon */} + + + {addressType} + + + + {/* Address text */} + + {truncateAddress(address, 6)} + + + {/* Copy button */} + + + + + + + ) +} diff --git a/src/components/leaderboard/index.ts b/src/components/leaderboard/index.ts new file mode 100644 index 0000000..eb0a217 --- /dev/null +++ b/src/components/leaderboard/index.ts @@ -0,0 +1,5 @@ +export * from './leaderboard-table' +export * from './leaderboard-stats' +export * from './medal-rank' +export * from './activity-sparkline' +export * from './address-cell' diff --git a/src/components/leaderboard/leaderboard-stats.tsx b/src/components/leaderboard/leaderboard-stats.tsx new file mode 100644 index 0000000..dd8e2bd --- /dev/null +++ b/src/components/leaderboard/leaderboard-stats.tsx @@ -0,0 +1,130 @@ +import { Grid, Card, Box, Typography, Skeleton } from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' +import { Iconify } from 'src/components/iconify' +import { fCurrency, fNumber } from 'src/utils/format-number' +import AnimatedNumbers from 'react-animated-numbers' + +interface LeaderboardStatsProps { + totalUsers: number + totalVolumeUsd: number + avgVolumePerUser: number + topUserAddress: string + topUserVolume: number + isLoading?: boolean +} + +interface StatCardProps { + title: string + value: number | string + icon: string + color: string + isDollar?: boolean + isLoading?: boolean +} + +function StatCard({ title, value, icon, color, isDollar, isLoading }: StatCardProps) { + const theme = useTheme() + + const textStyle = { + fontSize: '1.5rem', + fontWeight: 'bold', + fontFamily: 'Barlow', + } + + return ( + + + + + {title} + + + + {isLoading ? ( + + ) : typeof value === 'number' ? ( + + {isDollar && $} + + + ) : ( + + {value} + + )} + + ) +} + +export function LeaderboardStats({ + totalUsers, + totalVolumeUsd, + avgVolumePerUser, + topUserAddress, + topUserVolume, + isLoading, +}: LeaderboardStatsProps) { + const theme = useTheme() + + const stats = [ + { + title: 'Total Users', + value: totalUsers, + icon: 'solar:users-group-rounded-bold-duotone', + color: theme.palette.info.main, + isDollar: false, + }, + { + title: 'Total Volume', + value: totalVolumeUsd, + icon: 'solar:wallet-money-bold-duotone', + color: theme.palette.success.main, + isDollar: true, + }, + { + title: 'Avg Volume/User', + value: avgVolumePerUser, + icon: 'solar:chart-square-bold-duotone', + color: theme.palette.warning.main, + isDollar: true, + }, + { + title: 'Top User Volume', + value: topUserVolume, + icon: 'solar:crown-bold-duotone', + color: '#FFD700', + isDollar: true, + }, + ] + + return ( + + {stats.map((stat, index) => ( + + + + ))} + + ) +} diff --git a/src/components/leaderboard/leaderboard-table.tsx b/src/components/leaderboard/leaderboard-table.tsx new file mode 100644 index 0000000..344d300 --- /dev/null +++ b/src/components/leaderboard/leaderboard-table.tsx @@ -0,0 +1,404 @@ +'use client' + +import { useState, useEffect, useMemo } from 'react' +import { + Box, + Card, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TablePagination, + Tabs, + Tab, + Typography, + Skeleton, + Chip, + Tooltip, + IconButton, + ToggleButtonGroup, + ToggleButton, +} from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' +import { useRouter } from 'next/navigation' +import useSWR from 'swr' +import { endpoints, fetcher } from 'src/utils/axios' +import { getNetwork } from 'src/hooks/get-network-storage' +import { useGlobalContext } from 'src/provider/global-provider' +import { fCurrency, fNumber, fShortenNumber } from 'src/utils/format-number' +import { fDate } from 'src/utils/format-time' +import { Iconify } from 'src/components/iconify' +import { paths } from 'src/routes/paths' + +import { MedalRank } from './medal-rank' +import { AddressCell } from './address-cell' +import { ActivitySparkline } from './activity-sparkline' +import { LeaderboardStats } from './leaderboard-stats' + +import type { LeaderboardResponse } from 'src/pages/api/leaderboard' +import type { SparklineResponse } from 'src/pages/api/leaderboard-sparklines' + +type AddressTypeFilter = 'all' | 'sui' | 'eth' +type SortByOption = 'volume' | 'count' + +const ROWS_PER_PAGE = 25 + +export function LeaderboardTable() { + const theme = useTheme() + const router = useRouter() + const network = getNetwork() + const { timePeriod } = useGlobalContext() + + const [page, setPage] = useState(0) + const [addressType, setAddressType] = useState('all') + const [sortBy, setSortBy] = useState('volume') + + // Reset page when filters change + useEffect(() => { + setPage(0) + }, [addressType, sortBy, timePeriod]) + + // Build query string + const queryString = useMemo(() => { + const params = new URLSearchParams({ + network, + period: timePeriod, + addressType, + sortBy, + limit: String(ROWS_PER_PAGE), + offset: String(page * ROWS_PER_PAGE), + }) + return params.toString() + }, [network, timePeriod, addressType, sortBy, page]) + + // Fetch leaderboard data + const { data, isLoading, error } = useSWR( + `${endpoints.leaderboard}?${queryString}`, + fetcher, + { + revalidateOnFocus: false, + dedupingInterval: 30000, + }, + ) + + // Get addresses for sparklines + const addresses = useMemo(() => { + return data?.users?.map(u => u.address) || [] + }, [data?.users]) + + // Fetch sparklines data + const { data: sparklineData } = useSWR( + addresses.length > 0 + ? `${endpoints.leaderboardSparklines}?network=${network}&addresses=${addresses.join(',')}` + : null, + fetcher, + { + revalidateOnFocus: false, + dedupingInterval: 60000, + }, + ) + + const handleChangePage = (_: unknown, newPage: number) => { + setPage(newPage) + } + + const handleAddressTypeChange = (_: React.SyntheticEvent, newValue: AddressTypeFilter) => { + if (newValue !== null) { + setAddressType(newValue) + } + } + + const handleSortChange = (_: React.MouseEvent, newSort: SortByOption) => { + if (newSort !== null) { + setSortBy(newSort) + } + } + + const handleRowClick = (address: string, addressType: 'sui' | 'eth') => { + const queryParam = addressType === 'sui' ? 'suiAddress' : 'ethAddress' + router.push(`${paths.profile.root}?${queryParam}=0x${address}`) + } + + const users = data?.users || [] + const total = data?.total || 0 + const stats = data?.stats + + return ( + + {/* Stats Cards */} + + + {/* Main Card */} + + {/* Header with tabs and filters */} + + {/* Address Type Tabs */} + + } + iconPosition="start" + /> + + } + iconPosition="start" + /> + + } + iconPosition="start" + /> + + + {/* Sort By Toggle */} + + + Sort by: + + + + + Volume + + + + Tx Count + + + + + + {/* Table */} + + + + + + Rank + + Address + Volume (USD) + Transactions + Top Token + + + + Activity + + + + + First Active + Last Active + + + + {isLoading ? ( + // Loading skeletons + Array.from({ length: 10 }).map((_, index) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + )) + ) : users.length === 0 ? ( + + + + + + No users found for the selected filters + + + + + ) : ( + users.map((user, index) => { + const globalRank = page * ROWS_PER_PAGE + index + 1 + const sparklineValues = + sparklineData?.sparklines?.[user.address] || [] + + return ( + + handleRowClick(user.address, user.address_type) + } + > + + + + + + + + + {fCurrency(user.total_volume_usd)} + + + + + {fNumber(user.transaction_count)} + + + + + + + + + + + {fDate(user.first_tx_date)} + + + + + {fDate(user.last_tx_date)} + + + + ) + }) + )} + +
+
+ + {/* Pagination */} + +
+
+ ) +} diff --git a/src/components/leaderboard/medal-rank.tsx b/src/components/leaderboard/medal-rank.tsx new file mode 100644 index 0000000..72772ca --- /dev/null +++ b/src/components/leaderboard/medal-rank.tsx @@ -0,0 +1,76 @@ +import { Box, Typography } from '@mui/material' +import { useTheme, alpha } from '@mui/material/styles' + +interface MedalRankProps { + rank: number +} + +const MEDAL_COLORS = { + 1: { primary: '#FFD700', secondary: '#FFA500', label: '1st' }, // Gold + 2: { primary: '#C0C0C0', secondary: '#A8A8A8', label: '2nd' }, // Silver + 3: { primary: '#CD7F32', secondary: '#B87333', label: '3rd' }, // Bronze +} + +export function MedalRank({ rank }: MedalRankProps) { + const theme = useTheme() + const medalConfig = MEDAL_COLORS[rank as keyof typeof MEDAL_COLORS] + + if (!medalConfig) { + // Regular rank display for non-medal positions + return ( + + + {rank} + + + ) + } + + // Medal display for top 3 + return ( + + + {rank} + + + ) +} diff --git a/src/layouts/config-nav-dashboard.tsx b/src/layouts/config-nav-dashboard.tsx index 531edcc..46eb104 100644 --- a/src/layouts/config-nav-dashboard.tsx +++ b/src/layouts/config-nav-dashboard.tsx @@ -53,6 +53,10 @@ export const navData = [ ], }, + { + items: [{ title: 'Leaderboard', path: paths.leaderboard.root, icon: ICONS.job }], + }, + { items: [{ title: 'Profile', path: paths.profile.root, icon: ICONS.course }], }, diff --git a/src/pages/api/leaderboard-sparklines.ts b/src/pages/api/leaderboard-sparklines.ts new file mode 100644 index 0000000..2f5f7e4 --- /dev/null +++ b/src/pages/api/leaderboard-sparklines.ts @@ -0,0 +1,103 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getNetworkConfig } from 'src/config/helper' +import { sendError, sendReply } from './utils' +import db from './database' +import dayjs from 'dayjs' + +export type SparklineData = { + address: string + data: number[] // Last 7 days of transaction counts +} + +export type SparklineResponse = { + sparklines: Record +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const networkConfig = getNetworkConfig({ req }) + + // Get addresses from query (comma-separated) + const addressesParam = req.query.addresses?.toString() || '' + const addresses = addressesParam + .split(',') + .map(a => a.trim().toLowerCase()) + .filter(a => a.length > 0) + + if (addresses.length === 0) { + sendReply(res, { sparklines: {} }) + return + } + + // Limit to max 50 addresses per request + const limitedAddresses = addresses.slice(0, 50) + + // Calculate date range (last 7 days) + const endDate = dayjs() + const startDate = endDate.subtract(7, 'day') + const fromTimestamp = startDate.valueOf() + const toTimestamp = endDate.valueOf() + + // Query to get daily transaction counts for each address over the last 7 days + const sparklineQuery = await db[networkConfig.network]` + WITH date_series AS ( + SELECT generate_series( + ${startDate.format('YYYY-MM-DD')}::date, + ${endDate.format('YYYY-MM-DD')}::date, + '1 day'::interval + )::date AS day + ), + user_daily_counts AS ( + SELECT + encode(sender_address, 'hex') as address, + DATE(TO_TIMESTAMP(timestamp_ms / 1000)) as tx_date, + COUNT(*) as daily_count + FROM public.token_transfer_data + WHERE + is_finalized = true + AND timestamp_ms BETWEEN ${fromTimestamp} AND ${toTimestamp} + AND sender_address IS NOT NULL + AND encode(sender_address, 'hex') = ANY(${limitedAddresses}) + GROUP BY encode(sender_address, 'hex'), DATE(TO_TIMESTAMP(timestamp_ms / 1000)) + ), + addresses_list AS ( + SELECT unnest(${limitedAddresses}::text[]) as address + ), + full_data AS ( + SELECT + al.address, + ds.day, + COALESCE(udc.daily_count, 0) as daily_count + FROM addresses_list al + CROSS JOIN date_series ds + LEFT JOIN user_daily_counts udc + ON al.address = udc.address + AND ds.day = udc.tx_date + ) + SELECT + address, + array_agg(daily_count ORDER BY day) as counts + FROM full_data + GROUP BY address + ` + + // Build response map + const sparklines: Record = {} + + sparklineQuery.forEach((row: any) => { + sparklines[row.address] = row.counts.map((c: any) => Number(c)) + }) + + // Fill in empty arrays for addresses with no data + limitedAddresses.forEach(addr => { + if (!sparklines[addr]) { + sparklines[addr] = [0, 0, 0, 0, 0, 0, 0, 0] + } + }) + + sendReply(res, { sparklines }) + } catch (error) { + console.error('Sparklines API error:', error) + sendError(res, error) + } +} diff --git a/src/pages/api/leaderboard.ts b/src/pages/api/leaderboard.ts new file mode 100644 index 0000000..c6229d2 --- /dev/null +++ b/src/pages/api/leaderboard.ts @@ -0,0 +1,188 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { getNetworkConfig, TimePeriod } from 'src/config/helper' +import { sendError, sendReply } from './utils' +import db from './database' +import { computerIntervals } from './cards' +import { getPrices } from './prices' + +export type LeaderboardUser = { + rank: number + address: string + address_type: 'sui' | 'eth' + total_volume_usd: number + transaction_count: number + tokens_used: number[] + most_used_token: string + most_used_token_count: number + first_tx_date: number + last_tx_date: number +} + +export type LeaderboardResponse = { + users: LeaderboardUser[] + total: number + stats: { + total_users: number + total_volume_usd: number + avg_volume_per_user: number + top_user_address: string + top_user_volume: number + } +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + const networkConfig = getNetworkConfig({ req }) + const timePeriod = (req.query.period as TimePeriod) || 'Last Week' + const addressType = (req.query.addressType as 'all' | 'sui' | 'eth') || 'all' + const sortBy = (req.query.sortBy as 'volume' | 'count') || 'volume' + const limit = Math.min(Number(req.query.limit) || 25, 100) + const offset = Number(req.query.offset) || 0 + + const { fromInterval, toInterval } = computerIntervals(timePeriod, false) + const prices = await getPrices(networkConfig.network) + + // Build price lookup map + const priceMap: Record = {} + prices.forEach((p: any) => { + priceMap[p.token_id] = { + price: Number(p.price), + deno: networkConfig.config.coins[p.token_id]?.deno || 1, + name: networkConfig.config.coins[p.token_id]?.name || 'Unknown', + } + }) + + // Build address type filter for WHERE clause + const addressTypeFilter = + addressType === 'all' + ? db[networkConfig.network]`` + : addressType === 'sui' + ? db[ + networkConfig.network + ]`AND t.destination_chain = ${networkConfig.config.networkId.ETH}` + : db[ + networkConfig.network + ]`AND t.destination_chain = ${networkConfig.config.networkId.SUI}` + + const orderByClause = + sortBy === 'volume' + ? db[networkConfig.network]`total_volume_usd DESC NULLS LAST` + : db[networkConfig.network]`transaction_count DESC` + + // Main leaderboard query - simplified to avoid GROUP BY issues + const leaderboardQuery = await db[networkConfig.network]` + WITH base_transactions AS ( + SELECT + encode(t.sender_address, 'hex') as address, + t.destination_chain, + t.token_id, + t.amount, + t.timestamp_ms, + p.price, + p.denominator, + (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd + FROM public.token_transfer_data t + JOIN public.prices p ON t.token_id = p.token_id + WHERE + t.is_finalized = true + AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} + AND t.sender_address IS NOT NULL + ${addressTypeFilter} + ), + user_stats AS ( + SELECT + address, + COUNT(*) as transaction_count, + array_agg(DISTINCT token_id) as tokens_used, + MIN(timestamp_ms) as first_tx_date, + MAX(timestamp_ms) as last_tx_date, + SUM(amount_usd) as total_volume_usd, + -- Determine address type based on majority of transactions + CASE + WHEN SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.SUI} THEN 1 ELSE 0 END) > + SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.ETH} THEN 1 ELSE 0 END) + THEN 'eth' + ELSE 'sui' + END as address_type + FROM base_transactions + GROUP BY address + ), + ranked_tokens AS ( + SELECT + address, + token_id, + COUNT(*) as token_count, + ROW_NUMBER() OVER ( + PARTITION BY address + ORDER BY COUNT(*) DESC + ) as rn + FROM base_transactions + GROUP BY address, token_id + ) + SELECT + us.address, + us.address_type, + us.transaction_count, + us.tokens_used, + us.first_tx_date, + us.last_tx_date, + COALESCE(us.total_volume_usd, 0) as total_volume_usd, + rt.token_id as most_used_token_id, + rt.token_count as most_used_token_count + FROM user_stats us + LEFT JOIN ranked_tokens rt ON us.address = rt.address AND rt.rn = 1 + ORDER BY ${orderByClause} + LIMIT ${limit} OFFSET ${offset} + ` + + // Count total users (simplified) + const totalCountQuery = await db[networkConfig.network]` + SELECT COUNT(DISTINCT encode(sender_address, 'hex')) as total + FROM public.token_transfer_data t + WHERE + is_finalized = true + AND timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} + AND sender_address IS NOT NULL + ${addressTypeFilter} + ` + + // Transform results + const users: LeaderboardUser[] = leaderboardQuery.map((row: any, index: number) => ({ + rank: offset + index + 1, + address: row.address, + address_type: row.address_type as 'sui' | 'eth', + total_volume_usd: Number(row.total_volume_usd) || 0, + transaction_count: Number(row.transaction_count), + tokens_used: row.tokens_used || [], + most_used_token: priceMap[row.most_used_token_id]?.name || 'Unknown', + most_used_token_count: Number(row.most_used_token_count) || 0, + first_tx_date: Number(row.first_tx_date), + last_tx_date: Number(row.last_tx_date), + })) + + // Calculate summary stats + const totalUsers = Number(totalCountQuery[0]?.total) || 0 + const totalVolumeUsd = users.reduce((sum, u) => sum + u.total_volume_usd, 0) + const topUser = users[0] + + const response: LeaderboardResponse = { + users, + total: totalUsers, + stats: { + total_users: totalUsers, + total_volume_usd: totalVolumeUsd, + avg_volume_per_user: users.length > 0 ? totalVolumeUsd / users.length : 0, + top_user_address: topUser?.address || '', + top_user_volume: topUser?.total_volume_usd || 0, + }, + } + + sendReply(res, response) + } catch (error) { + console.error('Leaderboard API error:', error) + sendError(res, { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + }) + } +} diff --git a/src/routes/paths.ts b/src/routes/paths.ts index ff8baff..b1bacd3 100644 --- a/src/routes/paths.ts +++ b/src/routes/paths.ts @@ -5,6 +5,7 @@ const ROOTS = { DASHBOARD: '/dashboard', TRANSACTIONS: '/transactions', PROFILE: '/profile', + LEADERBOARD: '/leaderboard', } // ---------------------------------------------------------------------- @@ -63,4 +64,9 @@ export const paths = { profile: { root: ROOTS.PROFILE, }, + + // LEADERBOARD + leaderboard: { + root: ROOTS.LEADERBOARD, + }, } diff --git a/src/utils/axios.ts b/src/utils/axios.ts index 6954bf5..5677f29 100644 --- a/src/utils/axios.ts +++ b/src/utils/axios.ts @@ -40,6 +40,8 @@ export const endpoints = { outflows: '/api/outflows', bridgeMetrics: '/api/bridge-metrics', // Add this line fees: '/api/fees', + leaderboard: '/api/leaderboard', + leaderboardSparklines: '/api/leaderboard-sparklines', volume: { daily: '/api/volume/daily', hourly: '/api/volume/hourly', From 470c4852bd23c427469481627427a3ca071f6532 Mon Sep 17 00:00:00 2001 From: novi Date: Tue, 20 Jan 2026 11:28:09 +0200 Subject: [PATCH 2/5] fix top cards --- src/pages/api/leaderboard.ts | 59 ++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/src/pages/api/leaderboard.ts b/src/pages/api/leaderboard.ts index c6229d2..0eb194f 100644 --- a/src/pages/api/leaderboard.ts +++ b/src/pages/api/leaderboard.ts @@ -135,15 +135,38 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) LIMIT ${limit} OFFSET ${offset} ` - // Count total users (simplified) - const totalCountQuery = await db[networkConfig.network]` - SELECT COUNT(DISTINCT encode(sender_address, 'hex')) as total - FROM public.token_transfer_data t - WHERE - is_finalized = true - AND timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} - AND sender_address IS NOT NULL - ${addressTypeFilter} + // Global stats query - always calculates across ALL users (not affected by sort/pagination) + const globalStatsQuery = await db[networkConfig.network]` + WITH base_transactions AS ( + SELECT + encode(t.sender_address, 'hex') as address, + t.token_id, + t.amount, + p.price, + p.denominator, + (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd + FROM public.token_transfer_data t + JOIN public.prices p ON t.token_id = p.token_id + WHERE + t.is_finalized = true + AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} + AND t.sender_address IS NOT NULL + ${addressTypeFilter} + ), + user_volumes AS ( + SELECT + address, + SUM(amount_usd) as total_volume_usd + FROM base_transactions + GROUP BY address + ) + SELECT + COUNT(*) as total_users, + COALESCE(SUM(total_volume_usd), 0) as total_volume_usd, + COALESCE(AVG(total_volume_usd), 0) as avg_volume_per_user, + (SELECT address FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_address, + (SELECT total_volume_usd FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_volume + FROM user_volumes ` // Transform results @@ -160,20 +183,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) last_tx_date: Number(row.last_tx_date), })) - // Calculate summary stats - const totalUsers = Number(totalCountQuery[0]?.total) || 0 - const totalVolumeUsd = users.reduce((sum, u) => sum + u.total_volume_usd, 0) - const topUser = users[0] + // Use global stats from the dedicated query + const globalStats = globalStatsQuery[0] const response: LeaderboardResponse = { users, - total: totalUsers, + total: Number(globalStats?.total_users) || 0, stats: { - total_users: totalUsers, - total_volume_usd: totalVolumeUsd, - avg_volume_per_user: users.length > 0 ? totalVolumeUsd / users.length : 0, - top_user_address: topUser?.address || '', - top_user_volume: topUser?.total_volume_usd || 0, + total_users: Number(globalStats?.total_users) || 0, + total_volume_usd: Number(globalStats?.total_volume_usd) || 0, + avg_volume_per_user: Number(globalStats?.avg_volume_per_user) || 0, + top_user_address: globalStats?.top_user_address || '', + top_user_volume: Number(globalStats?.top_user_volume) || 0, }, } From 7097f6b4cb4e0a0996837d3fe6b7dc8e2ade7e2d Mon Sep 17 00:00:00 2001 From: novi Date: Tue, 20 Jan 2026 11:37:52 +0200 Subject: [PATCH 3/5] fix activity tab --- .../leaderboard/leaderboard-table.tsx | 16 +--- src/pages/api/leaderboard-sparklines.ts | 79 ++++++++++--------- 2 files changed, 43 insertions(+), 52 deletions(-) diff --git a/src/components/leaderboard/leaderboard-table.tsx b/src/components/leaderboard/leaderboard-table.tsx index 344d300..d99765d 100644 --- a/src/components/leaderboard/leaderboard-table.tsx +++ b/src/components/leaderboard/leaderboard-table.tsx @@ -91,7 +91,7 @@ export function LeaderboardTable() { // Fetch sparklines data const { data: sparklineData } = useSWR( addresses.length > 0 - ? `${endpoints.leaderboardSparklines}?network=${network}&addresses=${addresses.join(',')}` + ? `${endpoints.leaderboardSparklines}?network=${network}&period=${encodeURIComponent(timePeriod)}&addresses=${addresses.join(',')}` : null, fetcher, { @@ -236,19 +236,7 @@ export function LeaderboardTable() { Transactions Top Token - - - Activity - - - + Activity First Active Last Active diff --git a/src/pages/api/leaderboard-sparklines.ts b/src/pages/api/leaderboard-sparklines.ts index 2f5f7e4..78f9087 100644 --- a/src/pages/api/leaderboard-sparklines.ts +++ b/src/pages/api/leaderboard-sparklines.ts @@ -1,21 +1,26 @@ import { NextApiRequest, NextApiResponse } from 'next' -import { getNetworkConfig } from 'src/config/helper' +import { getNetworkConfig, TimePeriod } from 'src/config/helper' import { sendError, sendReply } from './utils' import db from './database' +import { computerIntervals } from './cards' import dayjs from 'dayjs' export type SparklineData = { address: string - data: number[] // Last 7 days of transaction counts + data: number[] // Transaction counts per segment } export type SparklineResponse = { sparklines: Record } +// Number of segments for the sparkline based on time period +const SPARKLINE_SEGMENTS = 7 + export default async function handler(req: NextApiRequest, res: NextApiResponse) { try { const networkConfig = getNetworkConfig({ req }) + const timePeriod = (req.query.period as TimePeriod) || 'Last Week' // Get addresses from query (comma-separated) const addressesParam = req.query.addresses?.toString() || '' @@ -32,52 +37,46 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) // Limit to max 50 addresses per request const limitedAddresses = addresses.slice(0, 50) - // Calculate date range (last 7 days) - const endDate = dayjs() - const startDate = endDate.subtract(7, 'day') - const fromTimestamp = startDate.valueOf() - const toTimestamp = endDate.valueOf() + // Use same time intervals as leaderboard + const { fromInterval, toInterval } = computerIntervals(timePeriod, false) + + // Calculate segment duration based on time period + const totalDuration = toInterval - fromInterval + const segmentDuration = Math.floor(totalDuration / SPARKLINE_SEGMENTS) + + // Generate segment boundaries + const segments: { start: number; end: number }[] = [] + for (let i = 0; i < SPARKLINE_SEGMENTS; i++) { + segments.push({ + start: fromInterval + i * segmentDuration, + end: fromInterval + (i + 1) * segmentDuration, + }) + } - // Query to get daily transaction counts for each address over the last 7 days + // Query to get transaction counts per segment for each address const sparklineQuery = await db[networkConfig.network]` - WITH date_series AS ( - SELECT generate_series( - ${startDate.format('YYYY-MM-DD')}::date, - ${endDate.format('YYYY-MM-DD')}::date, - '1 day'::interval - )::date AS day - ), - user_daily_counts AS ( + WITH user_transactions AS ( SELECT encode(sender_address, 'hex') as address, - DATE(TO_TIMESTAMP(timestamp_ms / 1000)) as tx_date, - COUNT(*) as daily_count + timestamp_ms FROM public.token_transfer_data WHERE is_finalized = true - AND timestamp_ms BETWEEN ${fromTimestamp} AND ${toTimestamp} + AND timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} AND sender_address IS NOT NULL AND encode(sender_address, 'hex') = ANY(${limitedAddresses}) - GROUP BY encode(sender_address, 'hex'), DATE(TO_TIMESTAMP(timestamp_ms / 1000)) - ), - addresses_list AS ( - SELECT unnest(${limitedAddresses}::text[]) as address - ), - full_data AS ( - SELECT - al.address, - ds.day, - COALESCE(udc.daily_count, 0) as daily_count - FROM addresses_list al - CROSS JOIN date_series ds - LEFT JOIN user_daily_counts udc - ON al.address = udc.address - AND ds.day = udc.tx_date ) SELECT address, - array_agg(daily_count ORDER BY day) as counts - FROM full_data + ${db[networkConfig.network].unsafe( + segments + .map( + (seg, i) => + `COUNT(*) FILTER (WHERE timestamp_ms >= ${seg.start} AND timestamp_ms < ${seg.end}) as seg_${i}`, + ) + .join(', '), + )} + FROM user_transactions GROUP BY address ` @@ -85,13 +84,17 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const sparklines: Record = {} sparklineQuery.forEach((row: any) => { - sparklines[row.address] = row.counts.map((c: any) => Number(c)) + const counts: number[] = [] + for (let i = 0; i < SPARKLINE_SEGMENTS; i++) { + counts.push(Number(row[`seg_${i}`]) || 0) + } + sparklines[row.address] = counts }) // Fill in empty arrays for addresses with no data limitedAddresses.forEach(addr => { if (!sparklines[addr]) { - sparklines[addr] = [0, 0, 0, 0, 0, 0, 0, 0] + sparklines[addr] = Array(SPARKLINE_SEGMENTS).fill(0) } }) From bcfe0a117fac3e468d86e80bc85aa605d3e6b6e3 Mon Sep 17 00:00:00 2001 From: novi Date: Tue, 20 Jan 2026 13:50:26 +0200 Subject: [PATCH 4/5] fix PR comments --- .../leaderboard/activity-sparkline.tsx | 30 ++- src/components/leaderboard/address-cell.tsx | 3 +- .../leaderboard/leaderboard-stats.tsx | 7 +- .../leaderboard/leaderboard-table.tsx | 7 +- src/components/leaderboard/medal-rank.tsx | 6 +- src/layouts/config-nav-dashboard.tsx | 2 +- src/pages/api/leaderboard-sparklines.ts | 6 +- src/pages/api/leaderboard.ts | 196 +++++++++--------- 8 files changed, 124 insertions(+), 133 deletions(-) diff --git a/src/components/leaderboard/activity-sparkline.tsx b/src/components/leaderboard/activity-sparkline.tsx index b54a1b7..223ee7c 100644 --- a/src/components/leaderboard/activity-sparkline.tsx +++ b/src/components/leaderboard/activity-sparkline.tsx @@ -9,6 +9,11 @@ interface ActivitySparklineProps { color?: string } +interface Point { + x: number + y: number +} + export function ActivitySparkline({ data, width = 80, @@ -18,9 +23,9 @@ export function ActivitySparkline({ const theme = useTheme() const strokeColor = color || theme.palette.primary.main - const { path, maxValue, hasActivity } = useMemo(() => { + const { path, points, hasActivity } = useMemo(() => { if (!data || data.length === 0) { - return { path: '', maxValue: 0, hasActivity: false } + return { path: '', points: [] as Point[], hasActivity: false } } const max = Math.max(...data, 1) // Ensure at least 1 to avoid division by zero @@ -30,44 +35,35 @@ export function ActivitySparkline({ const chartWidth = width - padding * 2 const chartHeight = height - padding * 2 - const points = data.map((value, index) => { + const pts = data.map((value, index) => { const x = padding + (index / (data.length - 1 || 1)) * chartWidth const y = padding + chartHeight - (value / max) * chartHeight return { x, y } }) // Create SVG path - const pathData = points + const pathData = pts .map((point, index) => { if (index === 0) return `M ${point.x} ${point.y}` return `L ${point.x} ${point.y}` }) .join(' ') - return { path: pathData, maxValue: max, hasActivity: hasAct } + return { path: pathData, points: pts, hasActivity: hasAct } }, [data, width, height]) - // Create area path (for fill) - must be called before any conditional returns + // Create area path (for fill) - reuse points from above const areaPath = useMemo(() => { - if (!data || data.length === 0 || !path) return '' + if (points.length === 0 || !path) return '' const padding = 2 - const chartWidth = width - padding * 2 const chartHeight = height - padding * 2 - const max = Math.max(...data, 1) - - const points = data.map((value, index) => { - const x = padding + (index / (data.length - 1 || 1)) * chartWidth - const y = padding + chartHeight - (value / max) * chartHeight - return { x, y } - }) - const bottomY = padding + chartHeight const firstX = points[0]?.x || padding const lastX = points[points.length - 1]?.x || width - padding return `${path} L ${lastX} ${bottomY} L ${firstX} ${bottomY} Z` - }, [data, width, height, path]) + }, [points, path, width, height]) // No activity - show placeholder (after all hooks) if (!hasActivity) { diff --git a/src/components/leaderboard/address-cell.tsx b/src/components/leaderboard/address-cell.tsx index 37bc890..806e8f8 100644 --- a/src/components/leaderboard/address-cell.tsx +++ b/src/components/leaderboard/address-cell.tsx @@ -17,8 +17,7 @@ export function AddressCell({ address, addressType, onClick }: AddressCellProps) const handleCopy = async (e: React.MouseEvent) => { e.stopPropagation() try { - // Format address with proper prefix - const fullAddress = addressType === 'eth' ? `0x${address}` : `0x${address}` + const fullAddress = `0x${address}` await navigator.clipboard.writeText(fullAddress) setCopied(true) setTimeout(() => setCopied(false), 2000) diff --git a/src/components/leaderboard/leaderboard-stats.tsx b/src/components/leaderboard/leaderboard-stats.tsx index dd8e2bd..c7970d8 100644 --- a/src/components/leaderboard/leaderboard-stats.tsx +++ b/src/components/leaderboard/leaderboard-stats.tsx @@ -1,14 +1,12 @@ import { Grid, Card, Box, Typography, Skeleton } from '@mui/material' -import { useTheme, alpha } from '@mui/material/styles' +import { useTheme } from '@mui/material/styles' import { Iconify } from 'src/components/iconify' -import { fCurrency, fNumber } from 'src/utils/format-number' import AnimatedNumbers from 'react-animated-numbers' interface LeaderboardStatsProps { totalUsers: number totalVolumeUsd: number avgVolumePerUser: number - topUserAddress: string topUserVolume: number isLoading?: boolean } @@ -23,8 +21,6 @@ interface StatCardProps { } function StatCard({ title, value, icon, color, isDollar, isLoading }: StatCardProps) { - const theme = useTheme() - const textStyle = { fontSize: '1.5rem', fontWeight: 'bold', @@ -74,7 +70,6 @@ export function LeaderboardStats({ totalUsers, totalVolumeUsd, avgVolumePerUser, - topUserAddress, topUserVolume, isLoading, }: LeaderboardStatsProps) { diff --git a/src/components/leaderboard/leaderboard-table.tsx b/src/components/leaderboard/leaderboard-table.tsx index d99765d..3dc3f74 100644 --- a/src/components/leaderboard/leaderboard-table.tsx +++ b/src/components/leaderboard/leaderboard-table.tsx @@ -16,8 +16,6 @@ import { Typography, Skeleton, Chip, - Tooltip, - IconButton, ToggleButtonGroup, ToggleButton, } from '@mui/material' @@ -27,7 +25,7 @@ import useSWR from 'swr' import { endpoints, fetcher } from 'src/utils/axios' import { getNetwork } from 'src/hooks/get-network-storage' import { useGlobalContext } from 'src/provider/global-provider' -import { fCurrency, fNumber, fShortenNumber } from 'src/utils/format-number' +import { fCurrency, fNumber } from 'src/utils/format-number' import { fDate } from 'src/utils/format-time' import { Iconify } from 'src/components/iconify' import { paths } from 'src/routes/paths' @@ -74,7 +72,7 @@ export function LeaderboardTable() { }, [network, timePeriod, addressType, sortBy, page]) // Fetch leaderboard data - const { data, isLoading, error } = useSWR( + const { data, isLoading } = useSWR( `${endpoints.leaderboard}?${queryString}`, fetcher, { @@ -132,7 +130,6 @@ export function LeaderboardTable() { totalUsers={stats?.total_users || 0} totalVolumeUsd={stats?.total_volume_usd || 0} avgVolumePerUser={stats?.avg_volume_per_user || 0} - topUserAddress={stats?.top_user_address || ''} topUserVolume={stats?.top_user_volume || 0} isLoading={isLoading} /> diff --git a/src/components/leaderboard/medal-rank.tsx b/src/components/leaderboard/medal-rank.tsx index 72772ca..e6baf1b 100644 --- a/src/components/leaderboard/medal-rank.tsx +++ b/src/components/leaderboard/medal-rank.tsx @@ -6,9 +6,9 @@ interface MedalRankProps { } const MEDAL_COLORS = { - 1: { primary: '#FFD700', secondary: '#FFA500', label: '1st' }, // Gold - 2: { primary: '#C0C0C0', secondary: '#A8A8A8', label: '2nd' }, // Silver - 3: { primary: '#CD7F32', secondary: '#B87333', label: '3rd' }, // Bronze + 1: { primary: '#FFD700', secondary: '#FFA500' }, // Gold + 2: { primary: '#C0C0C0', secondary: '#A8A8A8' }, // Silver + 3: { primary: '#CD7F32', secondary: '#B87333' }, // Bronze } export function MedalRank({ rank }: MedalRankProps) { diff --git a/src/layouts/config-nav-dashboard.tsx b/src/layouts/config-nav-dashboard.tsx index 46eb104..5a7e976 100644 --- a/src/layouts/config-nav-dashboard.tsx +++ b/src/layouts/config-nav-dashboard.tsx @@ -54,7 +54,7 @@ export const navData = [ }, { - items: [{ title: 'Leaderboard', path: paths.leaderboard.root, icon: ICONS.job }], + items: [{ title: 'Leaderboard', path: paths.leaderboard.root, icon: ICONS.order }], }, { diff --git a/src/pages/api/leaderboard-sparklines.ts b/src/pages/api/leaderboard-sparklines.ts index 78f9087..0908329 100644 --- a/src/pages/api/leaderboard-sparklines.ts +++ b/src/pages/api/leaderboard-sparklines.ts @@ -3,7 +3,6 @@ import { getNetworkConfig, TimePeriod } from 'src/config/helper' import { sendError, sendReply } from './utils' import db from './database' import { computerIntervals } from './cards' -import dayjs from 'dayjs' export type SparklineData = { address: string @@ -101,6 +100,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) sendReply(res, { sparklines }) } catch (error) { console.error('Sparklines API error:', error) - sendError(res, error) + sendError(res, { + code: 500, + message: error instanceof Error ? error.message : 'Internal server error', + }) } } diff --git a/src/pages/api/leaderboard.ts b/src/pages/api/leaderboard.ts index 0eb194f..f28ae96 100644 --- a/src/pages/api/leaderboard.ts +++ b/src/pages/api/leaderboard.ts @@ -69,105 +69,107 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) ? db[networkConfig.network]`total_volume_usd DESC NULLS LAST` : db[networkConfig.network]`transaction_count DESC` - // Main leaderboard query - simplified to avoid GROUP BY issues - const leaderboardQuery = await db[networkConfig.network]` - WITH base_transactions AS ( + // Run both queries in parallel for better performance + const [leaderboardQuery, globalStatsQuery] = await Promise.all([ + // Main leaderboard query + db[networkConfig.network]` + WITH base_transactions AS ( + SELECT + encode(t.sender_address, 'hex') as address, + t.destination_chain, + t.token_id, + t.amount, + t.timestamp_ms, + p.price, + p.denominator, + (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd + FROM public.token_transfer_data t + JOIN public.prices p ON t.token_id = p.token_id + WHERE + t.is_finalized = true + AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} + AND t.sender_address IS NOT NULL + ${addressTypeFilter} + ), + user_stats AS ( + SELECT + address, + COUNT(*) as transaction_count, + array_agg(DISTINCT token_id) as tokens_used, + MIN(timestamp_ms) as first_tx_date, + MAX(timestamp_ms) as last_tx_date, + SUM(amount_usd) as total_volume_usd, + -- Determine address type based on majority of transactions + CASE + WHEN SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.SUI} THEN 1 ELSE 0 END) > + SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.ETH} THEN 1 ELSE 0 END) + THEN 'eth' + ELSE 'sui' + END as address_type + FROM base_transactions + GROUP BY address + ), + ranked_tokens AS ( + SELECT + address, + token_id, + COUNT(*) as token_count, + ROW_NUMBER() OVER ( + PARTITION BY address + ORDER BY COUNT(*) DESC + ) as rn + FROM base_transactions + GROUP BY address, token_id + ) SELECT - encode(t.sender_address, 'hex') as address, - t.destination_chain, - t.token_id, - t.amount, - t.timestamp_ms, - p.price, - p.denominator, - (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd - FROM public.token_transfer_data t - JOIN public.prices p ON t.token_id = p.token_id - WHERE - t.is_finalized = true - AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} - AND t.sender_address IS NOT NULL - ${addressTypeFilter} - ), - user_stats AS ( + us.address, + us.address_type, + us.transaction_count, + us.tokens_used, + us.first_tx_date, + us.last_tx_date, + COALESCE(us.total_volume_usd, 0) as total_volume_usd, + rt.token_id as most_used_token_id, + rt.token_count as most_used_token_count + FROM user_stats us + LEFT JOIN ranked_tokens rt ON us.address = rt.address AND rt.rn = 1 + ORDER BY ${orderByClause} + LIMIT ${limit} OFFSET ${offset} + `, + // Global stats query - always calculates across ALL users (not affected by sort/pagination) + db[networkConfig.network]` + WITH base_transactions AS ( + SELECT + encode(t.sender_address, 'hex') as address, + t.token_id, + t.amount, + p.price, + p.denominator, + (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd + FROM public.token_transfer_data t + JOIN public.prices p ON t.token_id = p.token_id + WHERE + t.is_finalized = true + AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} + AND t.sender_address IS NOT NULL + ${addressTypeFilter} + ), + user_volumes AS ( + SELECT + address, + SUM(amount_usd) as total_volume_usd + FROM base_transactions + GROUP BY address + ) SELECT - address, - COUNT(*) as transaction_count, - array_agg(DISTINCT token_id) as tokens_used, - MIN(timestamp_ms) as first_tx_date, - MAX(timestamp_ms) as last_tx_date, - SUM(amount_usd) as total_volume_usd, - -- Determine address type based on majority of transactions - CASE - WHEN SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.SUI} THEN 1 ELSE 0 END) > - SUM(CASE WHEN destination_chain = ${networkConfig.config.networkId.ETH} THEN 1 ELSE 0 END) - THEN 'eth' - ELSE 'sui' - END as address_type - FROM base_transactions - GROUP BY address - ), - ranked_tokens AS ( - SELECT - address, - token_id, - COUNT(*) as token_count, - ROW_NUMBER() OVER ( - PARTITION BY address - ORDER BY COUNT(*) DESC - ) as rn - FROM base_transactions - GROUP BY address, token_id - ) - SELECT - us.address, - us.address_type, - us.transaction_count, - us.tokens_used, - us.first_tx_date, - us.last_tx_date, - COALESCE(us.total_volume_usd, 0) as total_volume_usd, - rt.token_id as most_used_token_id, - rt.token_count as most_used_token_count - FROM user_stats us - LEFT JOIN ranked_tokens rt ON us.address = rt.address AND rt.rn = 1 - ORDER BY ${orderByClause} - LIMIT ${limit} OFFSET ${offset} - ` - - // Global stats query - always calculates across ALL users (not affected by sort/pagination) - const globalStatsQuery = await db[networkConfig.network]` - WITH base_transactions AS ( - SELECT - encode(t.sender_address, 'hex') as address, - t.token_id, - t.amount, - p.price, - p.denominator, - (t.amount::NUMERIC / p.denominator) * p.price::NUMERIC as amount_usd - FROM public.token_transfer_data t - JOIN public.prices p ON t.token_id = p.token_id - WHERE - t.is_finalized = true - AND t.timestamp_ms BETWEEN ${fromInterval} AND ${toInterval} - AND t.sender_address IS NOT NULL - ${addressTypeFilter} - ), - user_volumes AS ( - SELECT - address, - SUM(amount_usd) as total_volume_usd - FROM base_transactions - GROUP BY address - ) - SELECT - COUNT(*) as total_users, - COALESCE(SUM(total_volume_usd), 0) as total_volume_usd, - COALESCE(AVG(total_volume_usd), 0) as avg_volume_per_user, - (SELECT address FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_address, - (SELECT total_volume_usd FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_volume - FROM user_volumes - ` + COUNT(*) as total_users, + COALESCE(SUM(total_volume_usd), 0) as total_volume_usd, + COALESCE(AVG(total_volume_usd), 0) as avg_volume_per_user, + (SELECT address FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_address, + (SELECT total_volume_usd FROM user_volumes ORDER BY total_volume_usd DESC NULLS LAST LIMIT 1) as top_user_volume + FROM user_volumes + `, + ]) // Transform results const users: LeaderboardUser[] = leaderboardQuery.map((row: any, index: number) => ({ From 2de92e6bdc7f8edf3a26e7294ec7963a41030c83 Mon Sep 17 00:00:00 2001 From: novi Date: Tue, 20 Jan 2026 15:22:52 +0200 Subject: [PATCH 5/5] Update activity-sparkline.tsx --- .../leaderboard/activity-sparkline.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/components/leaderboard/activity-sparkline.tsx b/src/components/leaderboard/activity-sparkline.tsx index 223ee7c..98c36b3 100644 --- a/src/components/leaderboard/activity-sparkline.tsx +++ b/src/components/leaderboard/activity-sparkline.tsx @@ -1,6 +1,6 @@ import { Box } from '@mui/material' import { useTheme, alpha } from '@mui/material/styles' -import { useMemo } from 'react' +import { useMemo, useId } from 'react' interface ActivitySparklineProps { data: number[] @@ -22,6 +22,7 @@ export function ActivitySparkline({ }: ActivitySparklineProps) { const theme = useTheme() const strokeColor = color || theme.palette.primary.main + const gradientId = useId() const { path, points, hasActivity } = useMemo(() => { if (!data || data.length === 0) { @@ -104,23 +105,14 @@ export function ActivitySparkline({ {/* Gradient definition */} - + {/* Area fill */} - + {/* Line */}