feat: global leaderboard & player rankings (#57)#58
Conversation
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.
There was a problem hiding this comment.
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
/leaderboardUI (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.
| 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
left a comment
There was a problem hiding this comment.
parseMeta()currently validates onlyloginandoverall, but downstream consumers assume the returned object is a completeMetaEntry. For example,LeaderboardRowimmediately accessesRESULT_THEME[entry.finish], so partially populated or legacy Redis data could lead to runtime errors iffinishis missing or invalid.
Since the function is intended to treat malformed or legacy data as invalid, it would be safer to validate the full
MetaEntryshape (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.
| 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); |
| typeof m.overall === "number" && | ||
| Number.isFinite(m.overall) && |
| 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.
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
/leaderboardpage — cards ranked by overall, tier-tinted rows (rank, avatar, name, country flag, top-language logo, OVR pill), page-based pagination.after()hook.Design
gitfut:leaderboard:v1(score = overall) for ranking, plus a companion hashgitfut:leaderboard:meta:v1holding 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.lib/analytics.ts— every Redis call is wrapped, never throws, and no-ops whenREDIS_URLis unset. With Redis off, reads fall back to a sample-seed board so the page always renders.lib/leaderboard-core.ts(noserver-onlyor Redis import) so it's unit-tested; the thin Redis I/O sits inlib/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).after()callback returnsPromise.all([...])so Next'swaitUntilkeeps the Redis writes alive until they settle (no dropped writes when a serverless function freezes).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 … #428example).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,/leaderboardregistered as a dynamic route ·npm test→ 176/176 passing.