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 && (
+
+ {Array.from({ length: Math.min(remaining, 5) }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {/* Sentinel the observer watches. Rendered only while more remains, so
+ a finished list stops asking. */}
+ {hasMore && !isLoading && (
+
+ )}
+
+
+ {isLoadingMore
+ ? "Loading more leaderboard entries"
+ : hasMore
+ ? `${remaining} more entries available`
+ : "All leaderboard entries loaded"}
+
+
{isEmpty && !isLoading && (
No leaderboard data for this season yet.
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,
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);
+ });
+});
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;
+}