From 52564d5b291a95efb2c2bf851da63db8fdcec317 Mon Sep 17 00:00:00 2001 From: bilhokista <59991975+bilhokista@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:44:13 +0700 Subject: [PATCH 1/4] feat(leaderboard): paginate entries via infinite scroll --- frontend/src/lib/leaderboard.ts | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 frontend/src/lib/leaderboard.ts diff --git a/frontend/src/lib/leaderboard.ts b/frontend/src/lib/leaderboard.ts new file mode 100644 index 00000000..72a78158 --- /dev/null +++ b/frontend/src/lib/leaderboard.ts @@ -0,0 +1,79 @@ +/** + * Page-merging rules for the leaderboard. + * + * Kept out of `useLeaderboard` so the append behaviour can be tested without + * mounting a hook or mocking the network. + */ + +/** Minimum shape needed to merge; the real entry type is a superset. */ +export interface IdentifiableEntry { + user_id: string; + rank: number; +} + +export const LEADERBOARD_PAGE_SIZE = 50; + +/** + * Appends a freshly fetched page onto the entries already shown. + * + * Deduplicates by `user_id`, which matters more than it looks: the ranking is + * live, so a user who climbs between two requests can legitimately appear on + * both page 1 and page 2. Appending blindly would show them twice and give + * React two children with the same key. + * + * When a user appears twice the **incoming** copy wins, since it was read more + * recently, but it keeps the position it already held so rows do not jump + * around under the reader's cursor while they scroll. + */ +export function mergeLeaderboardPages( + existing: readonly T[], + incoming: readonly T[], +): T[] { + const positionOf = new Map(); + const merged: T[] = []; + + for (const entry of existing) { + positionOf.set(entry.user_id, merged.length); + merged.push(entry); + } + + for (const entry of incoming) { + const seenAt = positionOf.get(entry.user_id); + if (seenAt === undefined) { + positionOf.set(entry.user_id, merged.length); + merged.push(entry); + } else { + merged[seenAt] = entry; + } + } + + return merged; +} + +export interface PageInfo { + /** 1-based page number just returned. */ + page: number; + limit: number; + total: number; +} + +/** + * Whether another page exists after the one described by `info`. + * + * Guards against a `total` of 0 and against a `limit` of 0, which would + * otherwise divide by zero and report an endless list — an infinite scroll + * that never stops asking is worse than one that stops early. + */ +export function hasMorePages(info: PageInfo): boolean { + if (info.limit <= 0 || info.total <= 0) return false; + return info.page * info.limit < info.total; +} + +/** + * Rank the next page starts at, used to size the tail skeleton so it does not + * promise more rows than are actually left. + */ +export function remainingCount(info: PageInfo): number { + if (!hasMorePages(info)) return 0; + return info.total - info.page * info.limit; +} From b2699dc2b5f4429b4a318f42493e6771cdfc6cb3 Mon Sep 17 00:00:00 2001 From: bilhokista <59991975+bilhokista@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:44:15 +0700 Subject: [PATCH 2/4] feat(leaderboard): paginate entries via infinite scroll --- frontend/src/lib/leaderboard.test.ts | 127 +++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 frontend/src/lib/leaderboard.test.ts diff --git a/frontend/src/lib/leaderboard.test.ts b/frontend/src/lib/leaderboard.test.ts new file mode 100644 index 00000000..2a7953f2 --- /dev/null +++ b/frontend/src/lib/leaderboard.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +import { + IdentifiableEntry, + LEADERBOARD_PAGE_SIZE, + hasMorePages, + mergeLeaderboardPages, + remainingCount, +} from "./leaderboard"; + +interface Entry extends IdentifiableEntry { + username: string; +} + +const entry = (user_id: string, rank: number, username = user_id): Entry => ({ + user_id, + rank, + username, +}); + +const page1 = [entry("a", 1), entry("b", 2), entry("c", 3)]; +const page2 = [entry("d", 4), entry("e", 5)]; + +describe("mergeLeaderboardPages", () => { + it("appends a following page onto the entries already shown", () => { + const merged = mergeLeaderboardPages(page1, page2); + expect(merged.map((e) => e.user_id)).toEqual(["a", "b", "c", "d", "e"]); + }); + + it("does not duplicate a user who appears on both pages", () => { + // The ranking is live, so someone who climbs between two requests can + // legitimately be returned twice. + const overlapping = [entry("c", 3), entry("d", 4)]; + const merged = mergeLeaderboardPages(page1, overlapping); + + expect(merged.map((e) => e.user_id)).toEqual(["a", "b", "c", "d"]); + expect(merged).toHaveLength(4); + }); + + it("produces unique keys even when a whole page repeats", () => { + const merged = mergeLeaderboardPages(page1, page1); + expect(new Set(merged.map((e) => e.user_id)).size).toBe(merged.length); + }); + + it("takes the newer copy of a duplicated entry", () => { + const merged = mergeLeaderboardPages( + [entry("a", 5, "old-name")], + [entry("a", 2, "new-name")], + ); + + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ rank: 2, username: "new-name" }); + }); + + it("keeps a duplicated entry in the position it already held", () => { + // Moving it would make rows jump under the reader's cursor mid-scroll. + const merged = mergeLeaderboardPages(page1, [entry("a", 1)]); + expect(merged[0].user_id).toBe("a"); + expect(merged.map((e) => e.user_id)).toEqual(["a", "b", "c"]); + }); + + it("handles an empty incoming page", () => { + expect(mergeLeaderboardPages(page1, [])).toEqual(page1); + }); + + it("handles an empty starting list", () => { + expect(mergeLeaderboardPages([], page1)).toEqual(page1); + }); + + it("does not mutate either input", () => { + const before1 = JSON.stringify(page1); + const before2 = JSON.stringify(page2); + mergeLeaderboardPages(page1, page2); + expect(JSON.stringify(page1)).toBe(before1); + expect(JSON.stringify(page2)).toBe(before2); + }); + + it("accumulates correctly across three pages", () => { + const merged = mergeLeaderboardPages( + mergeLeaderboardPages(page1, page2), + [entry("e", 5), entry("f", 6)], + ); + expect(merged.map((e) => e.user_id)).toEqual(["a", "b", "c", "d", "e", "f"]); + }); +}); + +describe("hasMorePages", () => { + it("reports more while entries remain", () => { + expect(hasMorePages({ page: 1, limit: 50, total: 120 })).toBe(true); + expect(hasMorePages({ page: 2, limit: 50, total: 120 })).toBe(true); + }); + + it("stops on the last page", () => { + expect(hasMorePages({ page: 3, limit: 50, total: 120 })).toBe(false); + }); + + it("stops when the total lands exactly on a page boundary", () => { + expect(hasMorePages({ page: 2, limit: 50, total: 100 })).toBe(false); + }); + + it("reports nothing more for an empty leaderboard", () => { + expect(hasMorePages({ page: 1, limit: 50, total: 0 })).toBe(false); + }); + + it("refuses to scroll forever on a zero limit", () => { + // Dividing by a zero limit would otherwise describe an endless list. + expect(hasMorePages({ page: 1, limit: 0, total: 120 })).toBe(false); + }); +}); + +describe("remainingCount", () => { + it("counts the entries not yet loaded", () => { + expect(remainingCount({ page: 1, limit: 50, total: 120 })).toBe(70); + expect(remainingCount({ page: 2, limit: 50, total: 120 })).toBe(20); + }); + + it("is zero once everything is loaded", () => { + expect(remainingCount({ page: 3, limit: 50, total: 120 })).toBe(0); + expect(remainingCount({ page: 1, limit: 50, total: 0 })).toBe(0); + }); +}); + +describe("LEADERBOARD_PAGE_SIZE", () => { + it("is a positive page size", () => { + expect(LEADERBOARD_PAGE_SIZE).toBeGreaterThan(0); + }); +}); From 181d389a9831cc2f0ea2054fcd9b0527ab84ab9c Mon Sep 17 00:00:00 2001 From: bilhokista <59991975+bilhokista@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:44:18 +0700 Subject: [PATCH 3/4] feat(leaderboard): paginate entries via infinite scroll --- frontend/src/hooks/useLeaderboard.ts | 113 ++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useLeaderboard.ts b/frontend/src/hooks/useLeaderboard.ts index 010e9bf4..6b2eb079 100644 --- a/frontend/src/hooks/useLeaderboard.ts +++ b/frontend/src/hooks/useLeaderboard.ts @@ -10,6 +10,12 @@ import { type SnapshotRankingEntry, } from "@/lib/api"; import { logHookError } from "@/hooks/useHookErrorMessage"; +import { + LEADERBOARD_PAGE_SIZE, + hasMorePages, + mergeLeaderboardPages, + remainingCount, +} from "@/lib/leaderboard"; // --------------------------------------------------------------------------- // Types @@ -30,6 +36,16 @@ export interface UseLeaderboardReturn { /** True when the server returned zero entries (not the same as loading). */ isEmpty: boolean; + // ── Pagination ────────────────────────────────────────────────────────── + /** Whether another page exists after the ones already loaded. */ + hasMore: boolean; + /** True while a subsequent page is in flight; false for the first load. */ + isLoadingMore: boolean; + /** Entries not yet loaded, so a tail skeleton can size itself honestly. */ + remaining: number; + /** Fetches the next page and appends it. No-op when already busy or done. */ + loadMore: () => Promise; + // ── Snapshot compare ──────────────────────────────────────────────────── /** ISO date string the compare snapshot is pinned to, or null when off. */ compareDate: string | null; @@ -58,7 +74,13 @@ export function useLeaderboard(): UseLeaderboardReturn { const [seasons, setSeasons] = useState([]); const [seasonId, setSeasonIdState] = useState(undefined); const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); + const [pageInfo, setPageInfo] = useState({ + page: 1, + limit: LEADERBOARD_PAGE_SIZE, + total: 0, + }); const [compareDate, setCompareDateState] = useState(null); const [snapshotEntries, setSnapshotEntries] = useState([]); @@ -73,6 +95,11 @@ export function useLeaderboard(): UseLeaderboardReturn { const abortRef = useRef(null); const snapshotAbortRef = useRef(null); + // Separate from `abortRef`: cancelling a "load more" must not tear down the + // first-page request, and changing season must cancel both. + const loadMoreAbortRef = useRef(null); + const isLoadingMoreRef = useRef(false); + // Keep a ref to the live entries so fetchSnapshot can read the latest value // without needing entries in its dependency array (avoids re-creating the // callback on every render). @@ -81,6 +108,19 @@ export function useLeaderboard(): UseLeaderboardReturn { entriesRef.current = entries; }, [entries]); + // Same trick for the values `loadMore` needs: keeping them in refs lets the + // callback stay referentially stable, which matters because useInfiniteScroll + // rebuilds its IntersectionObserver whenever the callback identity changes. + const pageInfoRef = useRef(pageInfo); + useEffect(() => { + pageInfoRef.current = pageInfo; + }, [pageInfo]); + + const seasonIdRef = useRef(seasonId); + useEffect(() => { + seasonIdRef.current = seasonId; + }, [seasonId]); + // --------------------------------------------------------------------------- // Load leaderboard entries + seasons list // --------------------------------------------------------------------------- @@ -97,17 +137,26 @@ export function useLeaderboard(): UseLeaderboardReturn { try { // Fetch leaderboard + seasons list in parallel. const [leaderboardData, seasonsData] = await Promise.all([ - getLeaderboard({ season_id: sid, limit: 100 }, { signal }), + getLeaderboard( + { season_id: sid, page: 1, limit: LEADERBOARD_PAGE_SIZE }, + { signal }, + ), getSeasons({ signal }), ]); if (signal.aborted) return; setEntries(leaderboardData.data); + setPageInfo({ + page: leaderboardData.page, + limit: leaderboardData.limit, + total: leaderboardData.total, + }); setSeasons(seasonsData.data); } catch (err) { if (signal.aborted) return; setEntries([]); + setPageInfo({ page: 1, limit: LEADERBOARD_PAGE_SIZE, total: 0 }); setError( logHookError(err, { fallbackMessage: "Failed to load leaderboard.", @@ -121,8 +170,16 @@ export function useLeaderboard(): UseLeaderboardReturn { // Re-fetch whenever the season changes. useEffect(() => { + // A page-2 request for the previous season would append foreign rows. + loadMoreAbortRef.current?.abort(); + isLoadingMoreRef.current = false; + setIsLoadingMore(false); + fetchLeaderboard(seasonId); - return () => abortRef.current?.abort(); + return () => { + abortRef.current?.abort(); + loadMoreAbortRef.current?.abort(); + }; }, [seasonId, fetchLeaderboard]); // --------------------------------------------------------------------------- @@ -208,6 +265,54 @@ export function useLeaderboard(): UseLeaderboardReturn { setCompareDateState(date); }, []); + const loadMore = useCallback(async () => { + // Guarded by a ref, not the state flag: two scroll events can fire before + // React re-renders, and both would otherwise fetch the same page. + if (isLoadingMoreRef.current) return; + if (!hasMorePages(pageInfoRef.current)) return; + + isLoadingMoreRef.current = true; + setIsLoadingMore(true); + + loadMoreAbortRef.current?.abort(); + const controller = new AbortController(); + loadMoreAbortRef.current = controller; + const { signal } = controller; + + const nextPage = pageInfoRef.current.page + 1; + + try { + const data = await getLeaderboard( + { + season_id: seasonIdRef.current, + page: nextPage, + limit: pageInfoRef.current.limit, + }, + { signal }, + ); + + if (signal.aborted) return; + + // Merge rather than concatenate: the ranking is live, so a user can + // legitimately appear on two consecutive pages. + setEntries((prev) => mergeLeaderboardPages(prev, data.data)); + setPageInfo({ page: data.page, limit: data.limit, total: data.total }); + } catch (err) { + if (signal.aborted) return; + // The first page is still on screen and still valid, so this is + // reported without clearing it. + setError( + logHookError(err, { + fallbackMessage: "Failed to load more leaderboard entries.", + hookName: "useLeaderboard/loadMore", + }), + ); + } finally { + isLoadingMoreRef.current = false; + if (!signal.aborted) setIsLoadingMore(false); + } + }, []); + const refetch = useCallback(() => { fetchLeaderboard(seasonId); if (compareDate) fetchSnapshot(compareDate, seasonId); @@ -221,6 +326,10 @@ export function useLeaderboard(): UseLeaderboardReturn { isLoading, error, isEmpty: !isLoading && !error && entries.length === 0, + hasMore: hasMorePages(pageInfo), + isLoadingMore, + remaining: remainingCount(pageInfo), + loadMore, compareDate, setCompareDate, snapshotEntries, From f03ca09c1def32b43834e1e44d873a5d8ca50a53 Mon Sep 17 00:00:00 2001 From: bilhokista <59991975+bilhokista@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:44:21 +0700 Subject: [PATCH 4/4] feat(leaderboard): paginate entries via infinite scroll --- .../app/(authenticated)/leaderboards/page.tsx | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/(authenticated)/leaderboards/page.tsx b/frontend/src/app/(authenticated)/leaderboards/page.tsx index 70eb9270..59597350 100644 --- a/frontend/src/app/(authenticated)/leaderboards/page.tsx +++ b/frontend/src/app/(authenticated)/leaderboards/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Trophy, Medal, Award, GitCompare, X } from "lucide-react"; import LeaderboardOverview from "@/component/leaderboard/LeaderboardOverview"; import LeaderboardFilters from "@/component/leaderboard/LeaderboardFilters"; @@ -9,6 +9,8 @@ import LeaderboardTable, { RankDelta, } from "@/component/leaderboard/LeaderboardTable"; import { useLeaderboard } from "@/hooks/useLeaderboard"; +import { useInfiniteScroll } from "@/hooks/useInfiniteScroll"; +import { Skeleton } from "@/component/ui/skeleton"; import type { LeaderboardEntryResponse } from "@/lib/api"; // --------------------------------------------------------------------------- @@ -269,10 +271,28 @@ export default function LeaderboardsPage() { snapshotDeltas, isSnapshotLoading, snapshotError, + hasMore, + isLoadingMore, + remaining, + loadMore, } = useLeaderboard(); const [showCompare, setShowCompare] = useState(false); + const { observerTarget, setHasMore } = useInfiniteScroll({ + onLoadMore: loadMore, + // Held off during the first load so the sentinel, which sits in an empty + // list and is therefore on screen, cannot request page 2 before page 1 + // has arrived. + enabled: !isLoading && !error, + }); + + // The hook owns `hasMore` as its own state; keep it in step with what the + // server actually reported. + useEffect(() => { + setHasMore(hasMore); + }, [hasMore, setHasMore]); + // Derive the display name for the currently selected season. const selectedSeason = seasons.find((s) => s.id === seasonId); const seasonName = selectedSeason?.name ?? "All Time"; @@ -369,6 +389,31 @@ export default function LeaderboardsPage() { isLoading={isLoading} showDelta={showCompare && snapshotDeltas.size > 0} /> + + {/* Tail skeleton — sized to what is genuinely left, so the list does + not promise more rows than the server has. */} + {isLoadingMore && ( + + )} + + {/* Sentinel the observer watches. Rendered only while more remains, so + a finished list stops asking. */} + {hasMore && !isLoading && ( +