diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index 3ccc454d..bfe0899f 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,6 +1,7 @@ "use client"; + import React, { useState, useMemo } from "react"; -import { Loader2, Search, Trophy, User } from "lucide-react"; +import { Loader2, Search, User } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { @@ -13,14 +14,15 @@ import { } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; -import { useLeaderboardData } from "@/hooks/useLeaderBoardData"; import { useUserData } from "@/hooks/useUserData"; +import { useLeaderboardData } from "@/hooks/useLeaderBoardData"; +import { ProfileWithBalance } from "@/types/supabase"; -function BalanceCell({ - balance, +function PortfolioValueCell({ + value, isLoading, }: { - balance: number | null; + value: number; isLoading: boolean; }) { if (isLoading) { @@ -30,23 +32,7 @@ function BalanceCell({ ); } - if (balance === null) { - return -; - } - return {balance.toFixed(2)} STX; -} - -function RankDisplay({ - rank, - isTopRank, -}: { - rank: number; - isTopRank: boolean; -}) { - if (isTopRank) { - return ; - } - return {rank}; + return ${value.toFixed(4)}; } export default function LeaderBoard() { @@ -70,12 +56,6 @@ export default function LeaderBoard() { ); }, [leaderboard, userData]); - // Find the highest balance to determine the top rank - const highestBalance = useMemo(() => { - if (!leaderboard?.length) return 0; - return Math.max(...leaderboard.map((p) => p.balance ?? 0)); - }, [leaderboard]); - if (isLoading) { return (
@@ -129,7 +109,9 @@ export default function LeaderBoard() { Participant Agent Address - Agent Balance + + Portfolio Value + @@ -145,8 +127,8 @@ export default function LeaderBoard() { )} - @@ -162,11 +144,11 @@ export default function LeaderBoard() { Rank Participant Agent Address - Agent Balance + Portfolio Value - {filteredLeaderboard.map((profile) => ( + {filteredLeaderboard.map((profile: ProfileWithBalance) => ( - 0 - } - /> + {profile.rank} @@ -194,8 +171,8 @@ export default function LeaderBoard() { )} - diff --git a/src/hooks/useLeaderBoardData.ts b/src/hooks/useLeaderBoardData.ts index 4b0c74dd..9d906a86 100644 --- a/src/hooks/useLeaderBoardData.ts +++ b/src/hooks/useLeaderBoardData.ts @@ -1,131 +1,154 @@ import { useQuery, useQueries } from "@tanstack/react-query"; import { supabase } from "@/utils/supabase/client"; +import { useMemo } from "react"; +import { BalanceResponse, TokenPrice, Profile, ProfileWithBalance } from "@/types/supabase"; -interface Profile { - email: string; - assigned_agent_address: string | null; +function normalizeContractId(contractId: string): string { + return contractId.split("::")[0]; } -interface ProfileWithBalance extends Profile { - balance: number | null; - rank: number; - isLoadingBalance: boolean; +// Fetch balance for a given address +async function fetchAgentBalance(address: string): Promise { + try { + const balanceResponse = await fetch(`/fetch?address=${address}`); + if (!balanceResponse.ok) throw new Error(`Failed to fetch balance data: ${balanceResponse.statusText}`); + return await balanceResponse.json(); + } catch (error) { + console.error(`Error fetching balance for ${address}:`, error); + throw error; + } +} + +// Fetch token prices +async function fetchTokenPrices(): Promise { + try { + const response = await fetch('https://cache.aibtc.dev/stx-city/tokens/tradable-full-details-tokens'); + if (!response.ok) throw new Error(`Failed to fetch token prices: ${response.statusText}`); + return await response.json(); + } catch (error) { + console.error("Error fetching token prices:", error); + throw error; + } +} + +// Calculate the total portfolio value +function calculatePortfolioValue(balances: BalanceResponse, tokenPrices: TokenPrice[]): number { + let totalValue = 0; + + // STX token calculation + const stxBalance = parseInt(balances.stx.balance) / 1_000_000; + const stxPrice = tokenPrices.find((token) => token.symbol === 'STX')?.metrics.price_usd || 0; + totalValue += stxBalance * stxPrice; + + // Calculate value for each fungible token + for (const [contractId, tokenData] of Object.entries(balances.fungible_tokens)) { + const normalizedContractId = normalizeContractId(contractId); + const balance = parseInt(tokenData.balance); + + const tokenInfo = tokenPrices.find( + (token) => token.contract_id && normalizeContractId(token.contract_id) === normalizedContractId + ); + + if (tokenInfo && tokenInfo.metrics.price_usd) { + // Adjust balance based on token decimals + const adjustedBalance = balance / Math.pow(10, tokenInfo.decimals); + const tokenValue = adjustedBalance * tokenInfo.metrics.price_usd; + totalValue += tokenValue; + } else { + console.warn(`No price found for token ${contractId}`); + } + } + + return totalValue; } async function fetchLeaderboardData(): Promise { try { const [participantResponse, adminResponse] = await Promise.all([ - supabase - .from("profiles") - .select("email, assigned_agent_address") - .eq("role", "Participant"), - supabase - .from("profiles") - .select("email, assigned_agent_address") - .eq("role", "Admin") + supabase.from("profiles").select("email, assigned_agent_address").eq("role", "Participant"), + supabase.from("profiles").select("email, assigned_agent_address").eq("role", "Admin") ]); if (participantResponse.error) throw participantResponse.error; if (adminResponse.error) throw adminResponse.error; - const combinedData: Profile[] = [ + return [ ...(participantResponse.data ?? []), ...(adminResponse.data ?? []) ].map((profile) => ({ email: profile.email, assigned_agent_address: profile.assigned_agent_address?.toUpperCase() ?? null, })); - - return combinedData; } catch (error) { console.error('Error fetching leaderboard data:', error); throw error; } } -async function fetchAgentBalance(address: string): Promise { - try { - const response = await fetch(`/fetch?address=${address}`); - if (!response.ok) { - throw new Error(`Failed to fetch balance: ${response.statusText}`); - } - const balanceData = await response.json(); - return balanceData.stx?.balance - ? parseInt(balanceData.stx.balance) / 1000000 - : 0; - } catch (error) { - console.error(`Error fetching balance for ${address}:`, error); - return null; - } -} - export function useLeaderboardData() { - // Fetch profiles - const { - data: profiles, - error: profilesError, - isLoading: isLoadingProfiles, - ...rest - } = useQuery({ - queryKey: ["leaderboardData"], + const tokenPricesQuery = useQuery({ + queryKey: ["tokenPrices"], + queryFn: fetchTokenPrices, + staleTime: 300000, + }); + + const profilesQuery = useQuery({ + queryKey: ["profiles"], queryFn: fetchLeaderboardData, - refetchOnWindowFocus: false, + staleTime: 30000, }); - // Fetch balances for each profile with an assigned agent const balanceQueries = useQueries({ - queries: (profiles ?? []).map((profile) => ({ - queryKey: ["agentBalance", profile.assigned_agent_address], - queryFn: () => - profile.assigned_agent_address - ? fetchAgentBalance(profile.assigned_agent_address) - : Promise.resolve(null), - enabled: !!profile.assigned_agent_address, + queries: (profilesQuery.data ?? []).map((profile) => ({ + queryKey: ["balance", profile.assigned_agent_address], + queryFn: async () => { + if (!profile.assigned_agent_address) return { portfolioValue: 0 }; + + const balances = await fetchAgentBalance(profile.assigned_agent_address); + const portfolioValue = calculatePortfolioValue(balances, tokenPricesQuery.data || []); + return { portfolioValue }; + }, + enabled: !!profile.assigned_agent_address && tokenPricesQuery.isSuccess, staleTime: 30000, retry: 2, })), }); - // Combine profiles with their balances and loading states - const leaderboardWithBalances: ProfileWithBalance[] = (profiles ?? []).map((profile, index) => ({ - ...profile, - balance: balanceQueries[index].data ?? null, - isLoadingBalance: balanceQueries[index].isLoading && !!profile.assigned_agent_address, - rank: 0, // Initial rank, will be updated in sorting - })); - - // Sort profiles by balance (null balances at the end) - const sortedLeaderboard = [...leaderboardWithBalances].sort((a, b) => { - // If both balances are null, maintain original order - if (a.balance === null && b.balance === null) return 0; - // Push null balances to the end - if (a.balance === null) return 1; - if (b.balance === null) return -1; - // Sort by balance in descending order - return b.balance - a.balance; - }); + const combinedData: ProfileWithBalance[] = useMemo(() => { + if (!profilesQuery.data) return []; - // Assign ranks (tied balances get the same rank) - const rankedLeaderboard = sortedLeaderboard.map((profile, index, array) => { - if (index === 0) { - return { ...profile, rank: 1 }; - } + const profiles = profilesQuery.data.map((profile, index) => ({ + ...profile, + portfolioValue: balanceQueries[index]?.data?.portfolioValue ?? 0, + isLoadingBalance: balanceQueries[index]?.isLoading ?? false, + rank: 0, + })); - const prevProfile = array[index - 1]; - // If current balance equals previous balance, assign same rank - // Otherwise, assign current position + 1 as rank - const rank = profile.balance === prevProfile.balance - ? prevProfile.rank - : index + 1; + const sortedProfiles = [...profiles].sort((a, b) => b.portfolioValue - a.portfolioValue); - return { ...profile, rank }; - }); + let currentRank = 0; + let currentValue = Infinity; + let increment = 0; + + return sortedProfiles.map((profile) => { + if (profile.portfolioValue < currentValue) { + currentRank += 1 + increment; + increment = 0; + currentValue = profile.portfolioValue; + } else { + increment++; + } + + return { + ...profile, + rank: currentRank, + }; + }); + }, [profilesQuery.data, balanceQueries]); return { - data: rankedLeaderboard, - error: profilesError, - isLoading: isLoadingProfiles, - isLoadingBalances: balanceQueries.some((query) => query.isLoading), - ...rest, + data: combinedData, + isLoading: profilesQuery.isLoading || tokenPricesQuery.isLoading, + error: profilesQuery.error || tokenPricesQuery.error, }; -} \ No newline at end of file +} diff --git a/src/types/supabase.ts b/src/types/supabase.ts index 323807c9..ad2d22cf 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -113,4 +113,38 @@ export interface PublicCrew { created_at: string; creator_email: string; agents: PublicAgent[]; + } + + export interface Profile { + email: string; + assigned_agent_address: string | null; + } + + export interface ProfileWithBalance extends Profile { + portfolioValue: number; + rank: number; + isLoadingBalance: boolean; + balances?: BalanceResponse; // Optional because it may be undefined until loaded + tokenPrices?: Record; // Map contract ID to token price + } + + + export interface BalanceResponse { + stx: { + balance: string; + }; + fungible_tokens: { + [key: string]: { + balance: string; + }; + }; + } + + export interface TokenPrice { + symbol?: string; + contract_id?: string; + metrics: { + price_usd: number; + }; + decimals:number; } \ No newline at end of file