diff --git a/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx b/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx index 2da0426971..cdb93bb93c 100644 --- a/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx +++ b/__tests__/components/waves/leaderboard/header/WaveleaderboardSort.test.tsx @@ -1,16 +1,16 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { WaveleaderboardSort } from '@/components/waves/leaderboard/header/WaveleaderboardSort'; -import { WaveDropsLeaderboardSort } from '@/hooks/useWaveDropsLeaderboard'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WaveleaderboardSort } from "@/components/waves/leaderboard/header/WaveleaderboardSort"; +import { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; -describe('WaveleaderboardSort', () => { - it('highlights active sort and triggers changes', async () => { +describe("WaveleaderboardSort", () => { + it("highlights active sort and triggers changes", async () => { const onSortChange = jest.fn(); const user = userEvent.setup(); const queryClient = new QueryClient(); - + render( { ); - const current = screen.getByText('Current Vote'); - expect(current.className).toContain('tw-bg-white/10'); + const current = screen.getByText("Current Vote"); + expect(current.className).toContain("tw-bg-white/10"); - await user.click(screen.getByText('Projected Vote')); + await user.click(screen.getByText("Projected Vote")); expect(onSortChange).toHaveBeenCalledWith( WaveDropsLeaderboardSort.RATING_PREDICTION ); - await user.click(screen.getByText('Newest')); + await user.click(screen.getByText("🔥 Hot")); + expect(onSortChange).toHaveBeenCalledWith(WaveDropsLeaderboardSort.TREND); + + await user.click(screen.getByText("Newest")); expect(onSortChange).toHaveBeenCalledWith( WaveDropsLeaderboardSort.CREATED_AT ); diff --git a/components/waves/leaderboard/header/WaveleaderboardSort.tsx b/components/waves/leaderboard/header/WaveleaderboardSort.tsx index b3b3834df7..6eacf5b9d0 100644 --- a/components/waves/leaderboard/header/WaveleaderboardSort.tsx +++ b/components/waves/leaderboard/header/WaveleaderboardSort.tsx @@ -1,13 +1,16 @@ -"use client" +"use client"; -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { debounce } from "lodash"; import { WaveDropsLeaderboardSort } from "@/hooks/useWaveDropsLeaderboard"; import { useQueryClient } from "@tanstack/react-query"; import { commonApiFetch } from "@/services/api/common-api"; import type { ApiDropsLeaderboardPage } from "@/generated/models/ApiDropsLeaderboardPage"; import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { WAVE_DROPS_PARAMS, getDefaultQueryRetry } from "@/components/react-query-wrapper/utils/query-utils"; +import { + WAVE_DROPS_PARAMS, + getDefaultQueryRetry, +} from "@/components/react-query-wrapper/utils/query-utils"; interface WaveleaderboardSortProps { readonly sort: WaveDropsLeaderboardSort; @@ -15,9 +18,13 @@ interface WaveleaderboardSortProps { readonly waveId?: string | undefined; } -const SORT_DIRECTION_MAP: Record = { +const SORT_DIRECTION_MAP: Record< + WaveDropsLeaderboardSort, + "ASC" | "DESC" | undefined +> = { [WaveDropsLeaderboardSort.RANK]: undefined, [WaveDropsLeaderboardSort.RATING_PREDICTION]: "DESC", + [WaveDropsLeaderboardSort.TREND]: "DESC", [WaveDropsLeaderboardSort.MY_REALTIME_VOTE]: undefined, [WaveDropsLeaderboardSort.CREATED_AT]: "DESC", }; @@ -28,75 +35,83 @@ export const WaveleaderboardSort: React.FC = ({ waveId, }) => { const queryClient = useQueryClient(); - - const prefetchSortImmediate = useCallback((targetSort: WaveDropsLeaderboardSort) => { - if (!waveId || targetSort === sort) return; - - const sortDirection = SORT_DIRECTION_MAP[targetSort]; - const queryKey = [ - QueryKey.DROPS_LEADERBOARD, - { - waveId, - page_size: WAVE_DROPS_PARAMS.limit, - sort: targetSort, - sort_direction: sortDirection, - }, - ]; - - queryClient - .prefetchInfiniteQuery({ - queryKey, - queryFn: async ({ pageParam }: { pageParam: number | null }) => { - const params: Record = { - page_size: WAVE_DROPS_PARAMS.limit.toString(), - sort: targetSort, - }; - - if (sortDirection) { - params["sort_direction"] = sortDirection; - } - - if (pageParam) { - params["page"] = `${pageParam}`; - } - - return await commonApiFetch({ - endpoint: `waves/${waveId}/leaderboard`, - params, - }); + + const prefetchSortImmediate = useCallback( + (targetSort: WaveDropsLeaderboardSort) => { + if (!waveId || targetSort === sort) return; + + const sortDirection = SORT_DIRECTION_MAP[targetSort]; + const queryKey = [ + QueryKey.DROPS_LEADERBOARD, + { + waveId, + page_size: WAVE_DROPS_PARAMS.limit, + sort: targetSort, + sort_direction: sortDirection, }, - initialPageParam: null, - getNextPageParam: (lastPage: ApiDropsLeaderboardPage) => { - if (targetSort === WaveDropsLeaderboardSort.MY_REALTIME_VOTE) { - const haveZeroVotes = lastPage.drops.some( - (drop) => drop.context_profile_context?.rating === 0 - ); - if (haveZeroVotes) { - return null; + ]; + + queryClient + .prefetchInfiniteQuery({ + queryKey, + queryFn: async ({ pageParam }: { pageParam: number | null }) => { + const params: Record = { + page_size: WAVE_DROPS_PARAMS.limit.toString(), + sort: targetSort, + }; + + if (sortDirection) { + params["sort_direction"] = sortDirection; } - } - return lastPage.next ? lastPage.page + 1 : null; - }, - pages: 1, - staleTime: 60000, - ...getDefaultQueryRetry(), - }) - .catch((error) => { - // Log prefetch errors for debugging while not blocking the UI - console.warn('Failed to prefetch leaderboard data:', { - waveId, - targetSort, - error: error.message || error + + if (typeof pageParam === "number") { + params["page"] = `${pageParam}`; + } + + return await commonApiFetch({ + endpoint: `waves/${waveId}/leaderboard`, + params, + }); + }, + pages: 1, + initialPageParam: null, + getNextPageParam: (lastPage: ApiDropsLeaderboardPage) => { + if (targetSort === WaveDropsLeaderboardSort.MY_REALTIME_VOTE) { + const haveZeroVotes = lastPage.drops.some( + (drop) => drop.context_profile_context?.rating === 0 + ); + if (haveZeroVotes) { + return null; + } + } + return lastPage.next ? lastPage.page + 1 : null; + }, + staleTime: 60000, + ...getDefaultQueryRetry(), + }) + .catch((error) => { + // Log prefetch errors for debugging while not blocking the UI + console.warn("Failed to prefetch leaderboard data:", { + waveId, + targetSort, + error: error?.message ?? error, + }); }); - }); - }, [queryClient, waveId, sort]); + }, + [queryClient, waveId, sort] + ); // Debounce prefetch to prevent excessive network requests on rapid hover events const prefetchSort = useMemo( () => debounce(prefetchSortImmediate, 300), [prefetchSortImmediate] ); - + + // Cancel pending debounced calls on unmount to avoid late network requests + useEffect(() => { + return () => prefetchSort.cancel(); + }, [prefetchSort]); + const getButtonClassName = (buttonSort: WaveDropsLeaderboardSort) => { const baseClass = "tw-px-4 tw-py-1.5 tw-text-xs tw-font-medium tw-border-0 tw-rounded-md tw-transition-colors"; @@ -111,8 +126,15 @@ export const WaveleaderboardSort: React.FC = ({ return (
+ diff --git a/hooks/useWaveDropsLeaderboard.ts b/hooks/useWaveDropsLeaderboard.ts index 438dc7f75a..8a35db3ac7 100644 --- a/hooks/useWaveDropsLeaderboard.ts +++ b/hooks/useWaveDropsLeaderboard.ts @@ -1,7 +1,6 @@ "use client"; -import { useCallback, useEffect, useState, useMemo } from "react"; -import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { useInfiniteQuery, useQuery, @@ -24,6 +23,7 @@ import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; export enum WaveDropsLeaderboardSort { RANK = "RANK", RATING_PREDICTION = "RATING_PREDICTION", + TREND = "TREND", MY_REALTIME_VOTE = "MY_REALTIME_VOTE", CREATED_AT = "CREATED_AT", } @@ -35,13 +35,16 @@ interface UseWaveDropsLeaderboardProps { readonly pausePolling?: boolean | undefined; } -const SORT_DIRECTION_MAP: Record = - { - [WaveDropsLeaderboardSort.RANK]: undefined, - [WaveDropsLeaderboardSort.RATING_PREDICTION]: "DESC", - [WaveDropsLeaderboardSort.MY_REALTIME_VOTE]: undefined, - [WaveDropsLeaderboardSort.CREATED_AT]: "DESC", - }; +const SORT_DIRECTION_MAP: Record< + WaveDropsLeaderboardSort, + "ASC" | "DESC" | undefined +> = { + [WaveDropsLeaderboardSort.RANK]: undefined, + [WaveDropsLeaderboardSort.RATING_PREDICTION]: "DESC", + [WaveDropsLeaderboardSort.TREND]: "DESC", + [WaveDropsLeaderboardSort.MY_REALTIME_VOTE]: undefined, + [WaveDropsLeaderboardSort.CREATED_AT]: "DESC", +}; const POLLING_DELAY = 3000; const ACTIVE_POLLING_INTERVAL = 5000; @@ -69,26 +72,9 @@ export function useWaveDropsLeaderboard({ const { isCapacitor } = useCapacitor(); const queryClient = useQueryClient(); - const [drops, setDrops] = useState([]); - const [hasInitialized, setHasInitialized] = useState(false); - const [haveNewDrops, setHaveNewDrops] = useState(false); const [canPoll, setCanPoll] = useState(false); - const [delayedPollingResult, setDelayedPollingResult] = useState< - ApiDropsLeaderboardPage | undefined - >(undefined); const isTabVisible = useTabVisibility(); - const [currentSort, setCurrentSort] = useState(sort); - - // Detect sort changes - const isSortChanging = currentSort !== sort; - - useEffect(() => { - if (currentSort !== sort) { - setCurrentSort(sort); - setDrops([]); - setHasInitialized(false); - } - }, [sort, currentSort]); + const pollingTimeoutRef = useRef | null>(null); const sortDirection = SORT_DIRECTION_MAP[sort]; @@ -130,7 +116,7 @@ export function useWaveDropsLeaderboard({ params["sort_direction"] = sortDirection; } - if (pageParam) { + if (typeof pageParam === "number") { params["page"] = `${pageParam}`; } @@ -166,16 +152,14 @@ export function useWaveDropsLeaderboard({ params["sort_direction"] = sortDirection; } - if (pageParam) { + if (typeof pageParam === "number") { params["page"] = `${pageParam}`; } - const results = await commonApiFetch({ + return await commonApiFetch({ endpoint: `waves/${waveId}/leaderboard`, params, }); - - return results; }, initialPageParam: null, getNextPageParam, @@ -184,7 +168,8 @@ export function useWaveDropsLeaderboard({ ...getDefaultQueryRetry(), }); - const processedDrops = useMemo(() => { + // Derive drops directly during render - no need for state + const drops = useMemo(() => { if (!data?.pages) return []; const mappedDrops = mapToExtendedDrops( @@ -206,35 +191,76 @@ export function useWaveDropsLeaderboard({ return uniqueDrops; }, [data, sort]); - useEffect(() => { - if (!data?.pages) { - return; - } - - setDrops(processedDrops); - setHasInitialized(true); - }, [processedDrops, data]); + // Derive hasInitialized from whether we have data + const hasInitialized = !!data?.pages; useDebounce(() => setCanPoll(true), 10000, [data]); - const { data: pollingResult } = useQuery({ + // Check if we can auto-refetch (derived during render) + const hasTempDrop = useMemo( + () => drops.some((drop) => drop.id.startsWith("temp-")), + [drops] + ); + const canAutoRefetch = isTabVisible && !hasTempDrop; + + // Helper to check if polling result has newer data than current drops + // Note: We compare against the max created_at across ALL loaded drops, + // not just drops[0], because drops may be sorted by RANK/TREND (not time) + const checkForNewDrops = useCallback( + (pollingData: ApiDropsLeaderboardPage): boolean => { + if (pollingData.drops.length === 0 || drops.length === 0) return false; + + const latestPolledDrop = pollingData.drops[0]; + + // Find the actual newest drop by created_at across all loaded drops + const newestExistingTimestamp = Math.max( + ...drops.map((drop) => new Date(drop.created_at).getTime()) + ); + + if (latestPolledDrop) { + const polledCreatedAt = new Date(latestPolledDrop.created_at).getTime(); + return polledCreatedAt > newestExistingTimestamp; + } + + return true; + }, + [drops] + ); + + // Polling query with select to determine if there are new drops + // Uses select to derive haveNewDrops directly from query data + // Always uses CREATED_AT sort to detect genuinely new drops regardless of main query's sort + const { data: haveNewDrops = false } = useQuery({ queryKey: [...queryKey, "polling"], queryFn: async () => { const params: Record = { page_size: "1", - sort: sort, + sort: WaveDropsLeaderboardSort.CREATED_AT, + sort_direction: "DESC", }; - if (sortDirection) { - params["sort_direction"] = sortDirection; - } - - return await commonApiFetch({ + const result = await commonApiFetch({ endpoint: `waves/${waveId}/leaderboard`, params, }); + + // Trigger refetch directly in the query callback when conditions are met + // This replaces the effect-based approach + if (canAutoRefetch && checkForNewDrops(result)) { + // Clear any existing timeout to prevent accumulation + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + } + // Use setTimeout to defer the refetch slightly and avoid React Query batching issues + pollingTimeoutRef.current = setTimeout(() => { + refetch(); + }, POLLING_DELAY); + } + + return result; }, - enabled: !haveNewDrops && canPoll && !pausePolling, + select: checkForNewDrops, + enabled: canPoll && !pausePolling, refetchInterval: isTabVisible ? ACTIVE_POLLING_INTERVAL : INACTIVE_POLLING_INTERVAL, @@ -245,53 +271,12 @@ export function useWaveDropsLeaderboard({ ...getDefaultQueryRetry(), }); - useEffect(() => { - if (pollingResult && !pausePolling) { - const timer = setTimeout(() => { - setDelayedPollingResult(pollingResult); - }, POLLING_DELAY); - - return () => clearTimeout(timer); - } - return; - }, [pollingResult, pausePolling]); - - useEffect(() => { - if (delayedPollingResult !== undefined) { - if (delayedPollingResult.drops.length > 0) { - const latestPolledDrop = delayedPollingResult.drops[0]; - - if (drops.length > 0) { - const latestExistingDrop = drops.at(-1); - - const polledCreatedAt = new Date( - latestPolledDrop?.created_at! - ).getTime(); - const existingCreatedAt = new Date( - latestExistingDrop?.created_at ?? 0 - ).getTime(); - - setHaveNewDrops(polledCreatedAt > existingCreatedAt); - } else { - setHaveNewDrops(true); - } - } else { - setHaveNewDrops(false); - } - } - }, [delayedPollingResult, drops]); - - useEffect(() => { - if (!haveNewDrops) return; - if (!isTabVisible) return; - const hasTempDrop = drops.some((drop) => drop.id.startsWith("temp-")); - if (hasTempDrop) return; - refetch(); - setHaveNewDrops(false); - }, [haveNewDrops, isTabVisible, drops]); - useEffect(() => { return () => { + // Clear polling timeout on unmount to prevent stale refetch calls + if (pollingTimeoutRef.current) { + clearTimeout(pollingTimeoutRef.current); + } queryClient.removeQueries({ queryKey: [QueryKey.DROPS, { waveId }], }); @@ -308,7 +293,7 @@ export function useWaveDropsLeaderboard({ drops, fetchNextPage, hasNextPage, - isFetching: isFetching || !hasInitialized || isSortChanging, + isFetching: isFetching || !hasInitialized, isFetchingNextPage, refetch, haveNewDrops, diff --git a/openapi.yaml b/openapi.yaml index 36c09dd5e8..8af135b1f6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5814,37 +5814,6 @@ components: type: number value_count: type: number - ApiMintMetrics: - type: object - required: - - card - - mint_time - - subscriptions - - mints - properties: - card: - type: number - format: int64 - mint_time: - type: number - format: int64 - subscriptions: - type: number - format: int64 - mints: - type: number - format: int64 - ApiMintMetricsPage: - type: object - required: - - data - allOf: - - $ref: "#/components/schemas/ApiPageBase" - properties: - data: - type: array - items: - $ref: "#/components/schemas/ApiMintMetrics" ApiCompleteMultipartUploadRequest: required: - upload_id diff --git a/scripts/worktree/copy.conf b/scripts/worktree/copy.conf index c1095bd974..72ebeb7926 100644 --- a/scripts/worktree/copy.conf +++ b/scripts/worktree/copy.conf @@ -8,3 +8,4 @@ # Environment files .env.development +.env.production