From 67c46ea1244645a6e71536c392086efc483225fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20=C3=96hrlund?= Date: Wed, 18 Oct 2023 21:05:21 +0200 Subject: [PATCH] add ft leaderboard to dashboard stats module --- garage/src/hooks/useRigStats.ts | 30 ++++++ garage/src/pages/Dashboard/modules/Stats.tsx | 96 +++++++++++++++++++- garage/src/utils/queries.ts | 25 +++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/garage/src/hooks/useRigStats.ts b/garage/src/hooks/useRigStats.ts index 0ec46515..e6bda40d 100644 --- a/garage/src/hooks/useRigStats.ts +++ b/garage/src/hooks/useRigStats.ts @@ -4,6 +4,7 @@ import { selectAccountStats, selectTopActivePilotCollections, selectTopFtPilotCollections, + selectTopFtEarners, } from "~/utils/queries"; import { useTablelandConnection } from "./useTablelandConnection"; @@ -160,3 +161,32 @@ export const useTopFtPilotCollections = () => { return { stats }; }; + +interface FtLeaderboardEntry { + address: string; + ft: number; +} + +export const useFtLeaderboard = (first: number) => { + const { db } = useTablelandConnection(); + + const [stats, setStats] = useState(); + + useEffect(() => { + let isCancelled = false; + + db.prepare(selectTopFtEarners(first)) + .all() + .then(({ results }) => { + if (isCancelled) return; + + setStats(results); + }); + + return () => { + isCancelled = true; + }; + }, [setStats]); + + return { stats }; +}; diff --git a/garage/src/pages/Dashboard/modules/Stats.tsx b/garage/src/pages/Dashboard/modules/Stats.tsx index 23184f90..16209010 100644 --- a/garage/src/pages/Dashboard/modules/Stats.tsx +++ b/garage/src/pages/Dashboard/modules/Stats.tsx @@ -1,4 +1,5 @@ -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { Link as RouterLink } from "react-router-dom"; import { Box, Heading, @@ -21,17 +22,22 @@ import { TabPanel, TabPanels, Text, + useBreakpointValue, + Show, } from "@chakra-ui/react"; +import { usePublicClient } from "wagmi"; import { useAccount } from "~/hooks/useAccount"; import { useAccountStats, useStats, useTopActivePilotCollections, useTopFtPilotCollections, + useFtLeaderboard, Stat, } from "~/hooks/useRigStats"; import { useNFTCollections, Collection } from "~/hooks/useNFTs"; -import { prettyNumber } from "~/utils/fmt"; +import { prettyNumber, truncateWalletAddress } from "~/utils/fmt"; +import { isValidAddress } from "~/utils/types"; const StatItem = ({ name, value }: { name: string; value: number }) => { return ( @@ -107,12 +113,88 @@ const CollectionToplist = ({ ); }; +const FTLeaderboard = ({ + data, +}: { + data: { address: string; ft: number }[]; +}) => { + const publicClient = usePublicClient(); + + const [ensNames, setEnsNames] = useState>({}); + + // Effect that reverse-resolves address->ens for all FT leaderboard entries + // in one batch multicall request + useEffect(() => { + let isCancelled = false; + if (data.length > 0) { + Promise.all( + data + .map(({ address }) => address) + .filter(isValidAddress) + .map(async (address) => { + const ens = await publicClient.getEnsName({ address }); + return [address, ens]; + }) + ).then((v) => { + if (!isCancelled) + setEnsNames(Object.fromEntries(v.filter(([, ens]) => ens))); + }); + } + return () => { + isCancelled = true; + }; + }, [data]); + + const { actingAsAddress } = useAccount(); + + const shouldTruncate = useBreakpointValue({ + base: true, + md: false, + }); + + return ( + + + + + + + + + {data?.slice(0, 15).map(({ address, ft }, index) => { + const isUser = + !!actingAsAddress && + actingAsAddress.toLowerCase() === address?.toLowerCase(); + + const truncatedAddress = address + ? truncateWalletAddress(address) + : ""; + const name = isUser + ? "You" + : ensNames[address] ?? + (shouldTruncate ? truncatedAddress : address); + + return ( + + + + + ); + })} + +
AddressFT
+ {name} + {prettyNumber(ft)}
+ ); +}; + export const Stats = (props: React.ComponentProps) => { const { actingAsAddress } = useAccount(); const { stats } = useStats(); const { stats: accountStats } = useAccountStats(actingAsAddress); const { stats: pilotStats } = useTopActivePilotCollections(); const { stats: ftStats } = useTopFtPilotCollections(); + const { stats: ftLeaderboard } = useFtLeaderboard(15); const contracts = useMemo(() => { if (!pilotStats || !ftStats) return; @@ -139,6 +221,10 @@ export const Stats = (props: React.ComponentProps) => { Global {actingAsAddress && You} Pilots + + FT Leaderboard + FT + @@ -187,6 +273,12 @@ export const Stats = (props: React.ComponentProps) => { + + + Top FT earners + {ftLeaderboard && } + + diff --git a/garage/src/utils/queries.ts b/garage/src/utils/queries.ts index 06805643..4d4f22a6 100644 --- a/garage/src/utils/queries.ts +++ b/garage/src/utils/queries.ts @@ -293,6 +293,31 @@ export const selectTopFtPilotCollections = (): string => { ORDER BY ft DESC`; }; +export const selectTopFtEarners = ( + first: number, + offset: number = 0 +): string => { + return ` + SELECT + address, + sum(ft) AS "ft" + FROM ( + SELECT + owner AS "address", + (coalesce(end_time, BLOCK_NUM(${chain.id})) - start_time) as "ft" + FROM ${pilotSessionsTable} + UNION ALL + SELECT + recipient AS "address", + amount AS "ft" + FROM ${ftRewardsTable} + ) + GROUP BY address + ORDER BY ft DESC + LIMIT ${first} + OFFSET ${offset}`; +}; + export const selectPilotSessionsForPilot = ( contract: string, tokenId: string