Skip to content

feat: global leaderboard & player rankings (#57)#58

Open
walidozich wants to merge 6 commits into
Younesfdj:masterfrom
walidozich:feat/global-leaderboard
Open

feat: global leaderboard & player rankings (#57)#58
walidozich wants to merge 6 commits into
Younesfdj:masterfrom
walidozich:feat/global-leaderboard

Conversation

@walidozich

Copy link
Copy Markdown

Implements the Global Leaderboard requested in #57 — a public, Redis-backed ranking of scouted GitFut cards by overall rating, with username search and pagination.

Closes #57

What's included

  • /leaderboard page — cards ranked by overall, tier-tinted rows (rank, avatar, name, country flag, top-language logo, OVR pill), page-based pagination.
  • Username search — type a GitHub username to scout it (adding it to the board) and jump to your rank with a few neighbours above/below. Failed scouts show a friendly note instead of crashing.
  • Auto-population — every scout (card JSON API, report page, and both sides of a duel) records the card onto the board via a best-effort write inside the existing after() hook.
  • Nav entry points — a LEADERBOARD link on the home hero and on the scout report page.

Design

  • Storage: a Redis sorted set gitfut:leaderboard:v1 (score = overall) for ranking, plus a companion hash gitfut:leaderboard:meta:v1 holding each row's display fields. The card cache (gitfut:card:v1:*) expires at 120 min, but the board is permanent — so rows render from durable data rather than a possibly-evicted card.
  • Best-effort / graceful degradation: mirrors lib/analytics.ts — every Redis call is wrapped, never throws, and no-ops when REDIS_URL is unset. With Redis off, reads fall back to a sample-seed board so the page always renders.
  • Testable core: pure rank / page / neighbour / seed logic lives in lib/leaderboard-core.ts (no server-only or Redis import) so it's unit-tested; the thin Redis I/O sits in lib/leaderboard.ts. This matches the repo's existing split between tested pure libs (lib/duel.ts) and untested best-effort server helpers (lib/analytics.ts).
  • Durable writes: each after() callback returns Promise.all([...]) so Next's waitUntil keeps the Redis writes alive until they settle (no dropped writes when a serverless function freezes).
  • Privacy: the board persists only a login plus a compact display record — usernames and GitHub-derived ratings are already public. No IP/geo, no private data.

Scope / deferred

Kept to a focused first cut. Deferred to follow-ups: filters (language / country / position), opt-out / removal, and competition-style tie ranking (v1 uses positional ranks, matching the issue's #1 … #428 example).

Note: the app has no auth, so the issue's "show the logged-in user's rank" is realised as "search your username to see your rank."

Testing

  • tests/leaderboard.test.ts — 15 cases covering rank assignment (page 1 and offset pages), page/neighbour clamping at top/bottom/middle edges, and sample-seed ordering.
  • npm run lint → 0 errors · npm run build → green, /leaderboard registered as a dynamic route · npm test → 176/176 passing.
  • Manually verified end-to-end: the board renders the sample seed with no token/Redis; with a GitHub token, live usernames scout and slot in by rating; nav links, pagination, and the search focus panel all work.

Ranking/paging/neighbour math and the Card->row mapping, with no Redis or
server-only dependency so it stays unit-testable. Sample-card seed used as the
Redis-off fallback. 15 vitest cases cover rank assignment, page/neighbour
clamping, and seed ordering.
Sorted set (score = overall) + companion meta hash, keyed by normalized login.
recordLeaderboardEntry / getLeaderboardPage / getRankFor / getNeighbors are all
best-effort: they never throw and fall back to the sample seed when Redis is
absent, empty, or erroring.
Wire recordLeaderboardEntry into the three scout sites (card JSON API, report
page, duel) alongside the existing recordScout, inside the same after() hook so
the Redis writes run post-response. Each after() returns Promise.all([...]) so
waitUntil keeps the writes alive until they settle; the report page records the
canonical card captured before the display-flag override.
New /leaderboard route: tier-tinted ranked rows (LeaderboardRow), page-based
pagination, and a username search (LeaderboardSearch) that scouts the login,
adds it to the board, and shows the searched player's rank plus a few neighbours.
Falls back to the sample-seed board when Redis is off.
Add a LEADERBOARD entry point in the home hero corner cluster and on the scout
report page so the board is reachable from the main surfaces.
Copilot AI review requested due to automatic review settings July 12, 2026 12:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements a new public “Global Leaderboard” feature backed by Redis, with a dynamic /leaderboard page that supports pagination and username focus/search, and integrates best-effort leaderboard writes into existing scouting flows.

Changes:

  • Added a pure leaderboard core module (ranking/page/window/seed + meta serialization) with unit tests.
  • Added Redis-backed leaderboard read/write helpers with sample-seed fallback when Redis is unavailable.
  • Introduced /leaderboard UI (search + focus panel + pagination) and added navigation entry points; updated scout/duel/API paths to record leaderboard entries after responses.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/leaderboard.test.ts Unit tests covering meta round-trip, rank assembly, paging, neighbor windows, and seed ordering.
lib/leaderboard.ts Server-only Redis I/O wrapper for recording entries and reading pages/ranks/neighbors with seed fallback.
lib/leaderboard-core.ts Pure leaderboard logic + meta serialization/parsing + seed generation.
components/LeaderboardSearch.tsx Client search form that navigates to focused leaderboard view.
components/LeaderboardRow.tsx Row renderer for ranked entries (avatar/flag/language/OVR pill).
components/AppShell.tsx Adds a prominent home-page link to the leaderboard.
app/u/[username]/page.tsx Records leaderboard entry alongside analytics after scouting a profile.
app/leaderboard/page.tsx New leaderboard page with search/focus flow and pagination UI.
app/api/card/[username]/route.ts Records leaderboard entry alongside analytics for card API scouts.
app/[username]/vs/[opponent]/page.tsx Records both duel participants to the leaderboard after the response.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/leaderboard-core.ts
Comment on lines +44 to +53
export function parseMeta(json: string | null): MetaEntry | null {
if (!json) return null;
try {
const m = JSON.parse(json) as MetaEntry;
if (!m || typeof m.login !== "string" || typeof m.overall !== "number") return null;
return m;
} catch {
return null;
}
}

@sheikhwasimuddin sheikhwasimuddin left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseMeta() currently validates only login and overall, but downstream consumers assume the returned object is a complete MetaEntry. For example, LeaderboardRow immediately accesses RESULT_THEME[entry.finish], so partially populated or legacy Redis data could lead to runtime errors if finish is missing or invalid.

Since the function is intended to treat malformed or legacy data as invalid, it would be safer to validate the full MetaEntry shape (or use a dedicated type guard) before returning the parsed object. This ensures corrupted or outdated cache entries are discarded gracefully instead of causing rendering failures.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

Comment thread app/leaderboard/page.tsx
Comment on lines +23 to +35
const { page: pageParam, focus } = await searchParams;
const requested = Number(pageParam) || 1;
const { entries, total, page, totalPages } = await getLeaderboardPage(requested, PAGE_SIZE);

// Focused search: scout the login (adds a new profile to the board), then read
// back its rank + neighbours. A scout failure yields a friendly note, not a crash.
let focusBlock: React.ReactNode = null;
if (focus) {
const res = await loadCard(focus);
if ("card" in res) {
await recordLeaderboardEntry(res.card);
const ranked = await getRankFor(focus);
const neighbors = await getNeighbors(focus, 3);
Comment thread lib/leaderboard-core.ts
Comment on lines +78 to +79
typeof m.overall === "number" &&
Number.isFinite(m.overall) &&
Comment thread lib/leaderboard.ts
const METAKEY = "gitfut:leaderboard:meta:v1";
const DEFAULT_PAGE_SIZE = 25;

const norm = (u: string) => u.trim().replace(/^@/, "").toLowerCase();
Previously parseMeta only checked login + overall, so a partial/legacy/corrupt
blob like { login, overall } was accepted and later crashed rendering
(RESULT_THEME[entry.finish].chip throws when finish is undefined/invalid).

Validate every MetaEntry field and that finish/position are real enum values via
a type guard, returning null otherwise — bad or outdated cache entries are now
discarded gracefully. Enum allow-lists are Record<Union,true> so the compiler
enforces every finish/position variant is listed. Adds tests for the partial-JSON
and invalid-enum cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Global Leaderboard & Player Rankings

3 participants