Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion frontend/src/app/(authenticated)/leaderboards/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 && (
<div className="space-y-2" aria-hidden="true" data-testid="leaderboard-tail-skeleton">
{Array.from({ length: Math.min(remaining, 5) }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-xl" />
))}
</div>
)}

{/* Sentinel the observer watches. Rendered only while more remains, so
a finished list stops asking. */}
{hasMore && !isLoading && (
<div ref={observerTarget} className="h-px w-full" aria-hidden="true" />
)}

<p className="sr-only" role="status">
{isLoadingMore
? "Loading more leaderboard entries"
: hasMore
? `${remaining} more entries available`
: "All leaderboard entries loaded"}
</p>

{isEmpty && !isLoading && (
<p className="text-center text-sm text-gray-500 py-4">
No leaderboard data for this season yet.
Expand Down
113 changes: 111 additions & 2 deletions frontend/src/hooks/useLeaderboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<void>;

// ── Snapshot compare ────────────────────────────────────────────────────
/** ISO date string the compare snapshot is pinned to, or null when off. */
compareDate: string | null;
Expand Down Expand Up @@ -58,7 +74,13 @@ export function useLeaderboard(): UseLeaderboardReturn {
const [seasons, setSeasons] = useState<SeasonListItem[]>([]);
const [seasonId, setSeasonIdState] = useState<string | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pageInfo, setPageInfo] = useState({
page: 1,
limit: LEADERBOARD_PAGE_SIZE,
total: 0,
});

const [compareDate, setCompareDateState] = useState<string | null>(null);
const [snapshotEntries, setSnapshotEntries] = useState<SnapshotRankingEntry[]>([]);
Expand All @@ -73,6 +95,11 @@ export function useLeaderboard(): UseLeaderboardReturn {
const abortRef = useRef<AbortController | null>(null);
const snapshotAbortRef = useRef<AbortController | null>(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<AbortController | null>(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).
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand All @@ -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.",
Expand All @@ -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]);

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down
127 changes: 127 additions & 0 deletions frontend/src/lib/leaderboard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading