Skip to content

Commit e99fc22

Browse files
authored
Merge pull request #121 from primev/leaderboard-miles-update
fix(miles): keep AppHeader and leaderboard miles in sync after settlement
2 parents 25cf92a + 663ca25 commit e99fc22

6 files changed

Lines changed: 223 additions & 59 deletions

File tree

src/app/api/fuul/leaderboard/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,11 @@ export async function GET(request: NextRequest) {
8888
const limit = Math.min(Math.max(parseInt(searchParams.get("limit") || "15", 10), 1), 100)
8989
const page = parseInt(searchParams.get("page") || "0", 10)
9090
const sort = searchParams.get("sort") || ""
91+
const forceRefresh = searchParams.get("refresh") === "1"
9192

92-
// Refresh cache if stale or missing
93-
const isStale = !rawCache || Date.now() - rawCache.timestamp >= LEADERBOARD_CACHE_STALE_TIME
93+
// Refresh cache if stale, missing, or explicitly requested
94+
const isStale =
95+
forceRefresh || !rawCache || Date.now() - rawCache.timestamp >= LEADERBOARD_CACHE_STALE_TIME
9496
if (isStale) {
9597
const allResults: FuulLeaderboardEntry[] = []
9698
let currentPage = 1

src/app/api/fuul/payouts/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,11 @@ export async function GET(request: NextRequest) {
2222
)
2323
}
2424

25-
const url = `${FUUL_TOTALS_URL}/${encodeURIComponent(address)}`
25+
// Fuul stores/keys addresses in lowercase (their leaderboard endpoint
26+
// returns lowercase wallets). A checksummed mixed-case lookup against
27+
// /payouts/totals/{address} 404s even when the wallet has credits, so
28+
// always normalize before hitting Fuul.
29+
const url = `${FUUL_TOTALS_URL}/${encodeURIComponent(address.toLowerCase())}`
2630

2731
const response = await fetch(url, {
2832
method: "GET",

src/components/dashboard/LeaderboardTable.tsx

Lines changed: 43 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
import { formatCurrency, formatNumber } from "@/lib/utils"
3131
import { trimWalletAddress } from "@/lib/analytics/services/leaderboard-transform"
3232
import { FEATURE_FLAGS } from "@/lib/feature-flags"
33+
import { useFuulMilesLeaderboard } from "@/hooks/use-fuul-miles-leaderboard"
34+
import { useUserPoints } from "@/hooks/use-user-points"
3335
import {
3436
TIER_THRESHOLDS,
3537
getTierFromVolume,
@@ -98,63 +100,59 @@ export const LeaderboardTable = ({
98100

99101
const [leaderboardMode, setLeaderboardMode] = useState<"volume" | "miles" | "stats">("miles")
100102

101-
// Single fetch for all Fuul leaderboard data (miles + referral cards)
102-
const [milesLeaderboard, setMilesLeaderboard] = useState<
103-
{ wallet: string; points: number; referrals: number; rank: number }[]
104-
>([])
105-
const [referralData, setReferralData] = useState<{
103+
// Shared Fuul miles leaderboard query. Listens for `refetch-user-miles`
104+
// events so that when a swap transitions from pending → processed the
105+
// miles tables refresh automatically, and the AppHeader stays in sync.
106+
const { data: fuulMilesData, isLoading: isMilesLoading } = useFuulMilesLeaderboard()
107+
const milesLeaderboard = useMemo(() => fuulMilesData?.entries ?? [], [fuulMilesData?.entries])
108+
const totalParticipants = fuulMilesData?.totalParticipants ?? 0
109+
const totalMiles = fuulMilesData?.totalMiles ?? 0
110+
const referralData = useMemo<{
106111
byPoints: ReferralLeaderEntry[]
107112
byRefs: ReferralLeaderEntry[]
108-
} | null>(null)
109-
const [isMilesLoading, setIsMilesLoading] = useState(false)
110-
// Server-provided totals (covers ALL participants, not just page 1)
111-
const [totalParticipants, setTotalParticipants] = useState(0)
112-
const [totalMiles, setTotalMiles] = useState(0)
113-
useEffect(() => {
114-
setIsMilesLoading(true)
115-
fetch("/api/fuul/leaderboard?limit=100&page=1&sort=miles")
116-
.then((res) => (res.ok ? res.json() : null))
117-
.then((json) => {
118-
if (!json) return
119-
const entries: { wallet: string; points: number; referrals: number; rank: number }[] =
120-
json.entries ||
121-
json.byPoints?.map((e: ReferralLeaderEntry, i: number) => ({ ...e, rank: i + 1 })) ||
122-
[]
123-
setMilesLeaderboard(entries)
124-
// Use server-provided totals (computed from full dataset)
125-
if (json.totalParticipants != null) setTotalParticipants(json.totalParticipants)
126-
else setTotalParticipants(entries.length)
127-
if (json.totalMiles != null) setTotalMiles(json.totalMiles)
128-
else
129-
setTotalMiles(entries.reduce((sum: number, e: { points: number }) => sum + e.points, 0))
130-
// Derive referral card data from the same dataset
131-
const byPoints = [...entries]
132-
.sort((a, b) => b.points - a.points)
133-
.slice(0, 10)
134-
.map((e, i) => ({ ...e, rank: i + 1 }))
135-
const byRefs = [...entries]
136-
.sort((a, b) => b.referrals - a.referrals)
137-
.slice(0, 10)
138-
.map((e, i) => ({ ...e, rank: i + 1 }))
139-
setReferralData({ byPoints, byRefs })
140-
})
141-
.catch(() => {})
142-
.finally(() => setIsMilesLoading(false))
143-
}, [])
113+
} | null>(() => {
114+
if (!milesLeaderboard.length) return null
115+
const byPoints = [...milesLeaderboard]
116+
.sort((a, b) => b.points - a.points)
117+
.slice(0, 10)
118+
.map((e, i) => ({ ...e, rank: i + 1 }))
119+
const byRefs = [...milesLeaderboard]
120+
.sort((a, b) => b.referrals - a.referrals)
121+
.slice(0, 10)
122+
.map((e, i) => ({ ...e, rank: i + 1 }))
123+
return { byPoints, byRefs }
124+
}, [milesLeaderboard])
125+
126+
// AppHeader badge value (per-user Fuul totals — updates faster than the
127+
// leaderboard dataset). When the leaderboard hasn't caught up yet we
128+
// surface the header value as the floor for the connected user's row
129+
// so the leaderboard never shows a smaller number than the badge.
130+
const { points: headerPoints } = useUserPoints()
144131

145132
// Find user in miles leaderboard
146133
const userMilesEntry = useMemo(() => {
147134
if (!userAddr || !milesLeaderboard.length) return null
148135
const trimmed = trimWalletAddress(userAddr.toLowerCase())
149-
return milesLeaderboard.find((e) => e.wallet === trimmed) ?? null
150-
}, [userAddr, milesLeaderboard])
136+
const found = milesLeaderboard.find((e) => e.wallet === trimmed) ?? null
137+
if (!found) return null
138+
if (headerPoints > found.points) {
139+
return { ...found, points: headerPoints }
140+
}
141+
return found
142+
}, [userAddr, milesLeaderboard, headerPoints])
151143

152-
// Wallet-to-miles lookup for volume leaderboard rows
144+
// Wallet-to-miles lookup for volume leaderboard rows. Override the
145+
// connected user's entry with the AppHeader value when it's higher.
153146
const milesByWallet = useMemo(() => {
154147
const map = new Map<string, number>()
155148
for (const e of milesLeaderboard) map.set(e.wallet, e.points)
149+
if (userAddr && headerPoints > 0) {
150+
const trimmed = trimWalletAddress(userAddr.toLowerCase())
151+
const current = map.get(trimmed) ?? 0
152+
if (headerPoints > current) map.set(trimmed, headerPoints)
153+
}
156154
return map
157-
}, [milesLeaderboard])
155+
}, [milesLeaderboard, userAddr, headerPoints])
158156

159157
// Next rank miles (person above user)
160158
const nextMilesRankEntry = useMemo(() => {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"use client"
2+
3+
import { useEffect } from "react"
4+
import { useQuery, useQueryClient } from "@tanstack/react-query"
5+
import { trimWalletAddress } from "@/lib/analytics/services/leaderboard-transform"
6+
import { LEADERBOARD_CACHE_STALE_TIME, LEADERBOARD_CACHE_GC_TIME } from "@/lib/constants"
7+
import { REFETCH_MILES_EVENT } from "@/lib/miles-events"
8+
9+
export interface FuulMilesEntry {
10+
wallet: string
11+
points: number
12+
referrals: number
13+
rank: number
14+
}
15+
16+
export interface FuulMilesLeaderboard {
17+
entries: FuulMilesEntry[]
18+
totalParticipants: number
19+
totalMiles: number
20+
}
21+
22+
export const FUUL_MILES_LEADERBOARD_QUERY_KEY = ["fuulMilesLeaderboard"] as const
23+
24+
async function fetchFuulMilesLeaderboard(refresh = false): Promise<FuulMilesLeaderboard> {
25+
const url = `/api/fuul/leaderboard?limit=100&page=1&sort=miles${refresh ? "&refresh=1" : ""}`
26+
const res = await fetch(url)
27+
if (!res.ok) throw new Error(`Failed to fetch miles leaderboard: ${res.status}`)
28+
const json = await res.json()
29+
const entries: FuulMilesEntry[] = (json.entries ||
30+
json.byPoints?.map((e: FuulMilesEntry, i: number) => ({ ...e, rank: i + 1 })) ||
31+
[]) as FuulMilesEntry[]
32+
33+
const totalParticipants =
34+
typeof json.totalParticipants === "number" ? json.totalParticipants : entries.length
35+
const totalMiles =
36+
typeof json.totalMiles === "number"
37+
? json.totalMiles
38+
: entries.reduce((sum, e) => sum + e.points, 0)
39+
40+
return { entries, totalParticipants, totalMiles }
41+
}
42+
43+
/**
44+
* Shared React Query-backed fetch of the Fuul miles leaderboard.
45+
* Consumers (LeaderboardTable, AppHeader via useUserPoints) read the same
46+
* cache, guaranteeing the connected user's Miles badge matches their
47+
* leaderboard row. Listens for `refetch-user-miles` events (fired when a
48+
* swap transitions from pending → processed) and refetches with a
49+
* server-cache bust so fresh settlement data is reflected immediately.
50+
*/
51+
export function useFuulMilesLeaderboard() {
52+
const queryClient = useQueryClient()
53+
54+
const query = useQuery<FuulMilesLeaderboard>({
55+
queryKey: FUUL_MILES_LEADERBOARD_QUERY_KEY,
56+
queryFn: () => fetchFuulMilesLeaderboard(false),
57+
staleTime: LEADERBOARD_CACHE_STALE_TIME,
58+
gcTime: LEADERBOARD_CACHE_GC_TIME,
59+
refetchOnWindowFocus: false,
60+
refetchOnMount: false,
61+
})
62+
63+
useEffect(() => {
64+
const handler = () => {
65+
queryClient.fetchQuery({
66+
queryKey: FUUL_MILES_LEADERBOARD_QUERY_KEY,
67+
queryFn: () => fetchFuulMilesLeaderboard(true),
68+
staleTime: 0,
69+
})
70+
}
71+
window.addEventListener(REFETCH_MILES_EVENT, handler)
72+
return () => window.removeEventListener(REFETCH_MILES_EVENT, handler)
73+
}, [queryClient])
74+
75+
return query
76+
}
77+
78+
/** Returns the leaderboard entry for the given wallet, or null. */
79+
export function findUserMilesEntry(
80+
data: FuulMilesLeaderboard | undefined,
81+
address: string | undefined | null
82+
): FuulMilesEntry | null {
83+
if (!data || !address) return null
84+
const trimmed = trimWalletAddress(address.toLowerCase())
85+
return data.entries.find((e) => e.wallet === trimmed) ?? null
86+
}

src/hooks/use-user-points.ts

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
1-
import { useState, useEffect, useCallback } from "react"
1+
import { useState, useEffect, useCallback, useRef } from "react"
22
import { useAccount } from "wagmi"
3+
import { REFETCH_MILES_EVENT, refetchMiles } from "@/lib/miles-events"
4+
import { getPendingSwapHashes, subscribeSwapSubmitted } from "@/lib/swap-events"
35

4-
const REFETCH_MILES_EVENT = "refetch-user-miles"
6+
export { REFETCH_MILES_EVENT, refetchMiles }
57

68
interface UseUserPointsReturn {
79
points: number
810
isLoading: boolean
911
}
1012

11-
/** Trigger a miles refetch from anywhere (e.g. after a successful swap). */
12-
export function refetchMiles() {
13-
window.dispatchEvent(new Event(REFETCH_MILES_EVENT))
14-
}
13+
/** Poll cadence for refreshing miles after a swap submission. */
14+
const POST_SWAP_POLL_INTERVAL_MS = 10_000
15+
/** Give up polling after this long if the value still hasn't changed. */
16+
const POST_SWAP_POLL_BUDGET_MS = 3 * 60 * 1000
1517

1618
export function useUserPoints(): UseUserPointsReturn {
1719
const { address } = useAccount()
18-
const [points, setPoints] = useState(0)
20+
const [payoutPoints, setPayoutPoints] = useState(0)
1921
const [isLoading, setIsLoading] = useState(false)
2022

23+
// The header reads from /payouts/totals/{address}, the per-user Fuul
24+
// endpoint that updates as soon as Fuul indexes the settlement. The
25+
// leaderboard data is computed on a slower cadence by Fuul, so we let
26+
// the badge update first and the leaderboard table catch up afterward
27+
// (both share refetch-user-miles, so they refresh in the same cycle).
28+
2129
const fetchPoints = useCallback((addr: string) => {
2230
let cancelled = false
2331
setIsLoading(true)
@@ -27,13 +35,13 @@ export function useUserPoints(): UseUserPointsReturn {
2735
.then((json) => {
2836
if (cancelled) return
2937
if (json?.success && typeof json.totalPoints === "number") {
30-
setPoints(json.totalPoints)
38+
setPayoutPoints(json.totalPoints)
3139
} else {
32-
setPoints(0)
40+
setPayoutPoints(0)
3341
}
3442
})
3543
.catch(() => {
36-
if (!cancelled) setPoints(0)
44+
if (!cancelled) setPayoutPoints(0)
3745
})
3846
.finally(() => {
3947
if (!cancelled) setIsLoading(false)
@@ -47,7 +55,7 @@ export function useUserPoints(): UseUserPointsReturn {
4755
// Initial fetch + refetch on address change
4856
useEffect(() => {
4957
if (!address) {
50-
setPoints(0)
58+
setPayoutPoints(0)
5159
setIsLoading(false)
5260
return
5361
}
@@ -62,5 +70,63 @@ export function useUserPoints(): UseUserPointsReturn {
6270
return () => window.removeEventListener(REFETCH_MILES_EVENT, handler)
6371
}, [address, fetchPoints])
6472

73+
// Track the latest points value so the post-swap poller can detect a
74+
// change without re-subscribing on every render.
75+
const points = payoutPoints
76+
const latestPointsRef = useRef(points)
77+
useEffect(() => {
78+
latestPointsRef.current = points
79+
}, [points])
80+
81+
// Active poll-loop bookkeeping. Settlement (and Fuul indexing of it) can
82+
// take ~30s+ after a swap is submitted — well past the single 5s
83+
// refetchMiles() that SwapConfirmationModal fires. Without this loop the
84+
// header never updates unless the user is on the dashboard (where
85+
// useUserSwaps does its own pending→processed detection).
86+
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
87+
const pollDeadlineRef = useRef<number>(0)
88+
const baselineRef = useRef<number>(0)
89+
90+
const stopPolling = useCallback(() => {
91+
if (pollTimerRef.current != null) {
92+
clearInterval(pollTimerRef.current)
93+
pollTimerRef.current = null
94+
}
95+
}, [])
96+
97+
const startPolling = useCallback(() => {
98+
// Extend the budget on each new swap submission. Capture the current
99+
// points value as the baseline; we stop as soon as it changes.
100+
pollDeadlineRef.current = Date.now() + POST_SWAP_POLL_BUDGET_MS
101+
baselineRef.current = latestPointsRef.current
102+
103+
if (pollTimerRef.current != null) return // already polling
104+
105+
pollTimerRef.current = setInterval(() => {
106+
if (Date.now() >= pollDeadlineRef.current) {
107+
stopPolling()
108+
return
109+
}
110+
if (latestPointsRef.current !== baselineRef.current) {
111+
stopPolling()
112+
return
113+
}
114+
refetchMiles()
115+
}, POST_SWAP_POLL_INTERVAL_MS)
116+
}, [stopPolling])
117+
118+
// Watch for swap submissions globally so the header refreshes even if
119+
// the user never visits the dashboard. Also resume polling on mount if
120+
// sessionStorage shows pending hashes from before this hook mounted.
121+
useEffect(() => {
122+
if (!address) return
123+
if (getPendingSwapHashes().length > 0) startPolling()
124+
const unsubscribe = subscribeSwapSubmitted(() => startPolling())
125+
return () => {
126+
unsubscribe()
127+
stopPolling()
128+
}
129+
}, [address, startPolling, stopPolling])
130+
65131
return { points, isLoading }
66132
}

src/lib/miles-events.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/** Fired when the user's Fuul miles may have changed (e.g. settlement finalized). */
2+
export const REFETCH_MILES_EVENT = "refetch-user-miles"
3+
4+
/** Trigger a miles refetch from anywhere (e.g. after a successful swap). */
5+
export function refetchMiles() {
6+
if (typeof window === "undefined") return
7+
window.dispatchEvent(new Event(REFETCH_MILES_EVENT))
8+
}

0 commit comments

Comments
 (0)