From 0a2180d75fdaa88fe1ed64f198ad4763786616b7 Mon Sep 17 00:00:00 2001 From: cherchali mohamed walid Date: Sun, 12 Jul 2026 13:35:09 +0100 Subject: [PATCH 1/6] feat(leaderboard): pure ranking core & tests 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. --- lib/leaderboard-core.ts | 102 +++++++++++++++++++++++++++++++ tests/leaderboard.test.ts | 124 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 lib/leaderboard-core.ts create mode 100644 tests/leaderboard.test.ts diff --git a/lib/leaderboard-core.ts b/lib/leaderboard-core.ts new file mode 100644 index 0000000..5ae20f6 --- /dev/null +++ b/lib/leaderboard-core.ts @@ -0,0 +1,102 @@ +import type { Card, Finish, Position } from "@/lib/scoring/types"; +import { SAMPLE_CARDS } from "@/lib/github/samples"; + +// A single rendered leaderboard row. +export interface LeaderboardEntry { + rank: number; // 1-based display rank + login: string; + name: string; + avatarUrl: string; + overall: number; + finish: Finish; + finishLabel: string; + position: Position; + country: string; + topLanguage: string | null; + langSlug: string | null; +} + +// Everything a row needs except its rank — this is what we persist per login in +// the meta hash (rank is derived at read time from the sorted set). +export type MetaEntry = Omit; + +// Card -> the compact display record. Single source of truth so serializeMeta +// (write path) and sampleSeedEntries (fallback) never drift apart. +export function cardToMeta(card: Card): MetaEntry { + return { + login: card.login, + name: card.name, + avatarUrl: card.avatarUrl, + overall: card.overall, + finish: card.finish, + finishLabel: card.finishLabel, + position: card.position, + country: card.country, + topLanguage: card.topLanguage ?? null, + langSlug: card.languageLogo?.slug ?? null, + }; +} + +export const serializeMeta = (card: Card): string => JSON.stringify(cardToMeta(card)); + +// Tolerant parse: any bad/missing/legacy JSON yields null so the row is skipped +// rather than crashing the page (best-effort, mirrors lib/scout.ts readCache). +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; + } +} + +// Zip parallel members + metas into ranked entries. rank is tied to the member's +// POSITION (startRank + i), so a missing-meta skip leaves later ranks correct. +export function assembleEntries( + members: string[], + metas: (MetaEntry | null)[], + startRank: number, +): LeaderboardEntry[] { + const out: LeaderboardEntry[] = []; + for (let i = 0; i < members.length; i++) { + const m = metas[i]; + if (!m) continue; + out.push({ rank: startRank + i, ...m }); + } + return out; +} + +// Inclusive ZREVRANGE indices for a page, with the page clamped into range. +export function pageBounds( + page: number, + size: number, + total: number, +): { start: number; stop: number; page: number; totalPages: number } { + const totalPages = Math.max(1, Math.ceil(total / size)); + const clamped = Math.min(Math.max(1, Math.floor(page) || 1), totalPages); + const start = (clamped - 1) * size; + return { start, stop: start + size - 1, page: clamped, totalPages }; +} + +// Inclusive ZREVRANGE indices for a ±span window around a 0-based rank index, +// clamped to the board. +export function neighborBounds( + rankIndex: number, + span: number, + total: number, +): { start: number; stop: number } { + return { + start: Math.max(0, rankIndex - span), + stop: Math.min(total - 1, rankIndex + span), + }; +} + +// Redis-off / empty-board fallback: rank the baked sample cards by overall desc +// (login as a stable tiebreak) so the page always renders something real. +export function sampleSeedEntries(): LeaderboardEntry[] { + return [...SAMPLE_CARDS] + .sort((a, b) => b.overall - a.overall || a.login.localeCompare(b.login)) + .map((c, i) => ({ rank: i + 1, ...cardToMeta(c) })); +} diff --git a/tests/leaderboard.test.ts b/tests/leaderboard.test.ts new file mode 100644 index 0000000..6c76d55 --- /dev/null +++ b/tests/leaderboard.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + cardToMeta, + serializeMeta, + parseMeta, + assembleEntries, + pageBounds, + neighborBounds, + sampleSeedEntries, + type MetaEntry, +} from "@/lib/leaderboard-core"; +import type { Card } from "@/lib/scoring/types"; + +const mkCard = (login: string, overall: number, over: Partial = {}): Card => ({ + login, + name: login, + avatarUrl: `https://avatars.githubusercontent.com/${login}.png`, + country: "us", + club: "neutral", + stats: { pac: 70, sho: 70, pas: 70, dri: 70, def: 70, phy: 70 }, + position: "CM", + family: "Playmaker", + baseOVR: overall, + overall, + finish: "gold", + finishLabel: "GOLD", + archetype: "Mezzala", + archetypeBlurb: "", + legacy: { L: 0 }, + topLanguage: "TypeScript", + languageLogo: { name: "TypeScript", slug: "typescript" }, + report: { + skillMoves: 3, + weakFoot: 3, + workRate: { attack: "Med", defense: "Med" }, + style: "Measured", + reasons: { skillMoves: "", weakFoot: "", workRate: "", style: "" }, + playstyles: [], + metrics: [], + }, + ...over, +}); + +const meta = (login: string, overall: number): MetaEntry => cardToMeta(mkCard(login, overall)); + +describe("cardToMeta / serialize / parse", () => { + it("captures the display fields and survives a JSON round-trip", () => { + const m = cardToMeta(mkCard("torvalds", 99)); + expect(m).toMatchObject({ login: "torvalds", overall: 99, langSlug: "typescript", topLanguage: "TypeScript" }); + expect(parseMeta(serializeMeta(mkCard("torvalds", 99)))).toEqual(m); + }); + + it("maps a card with no language logo to null slug", () => { + const m = cardToMeta(mkCard("nolang", 60, { languageLogo: null, topLanguage: null })); + expect(m.langSlug).toBeNull(); + expect(m.topLanguage).toBeNull(); + }); + + it("parseMeta returns null on null / garbage", () => { + expect(parseMeta(null)).toBeNull(); + expect(parseMeta("not json")).toBeNull(); + expect(parseMeta("{}")).toBeNull(); + }); +}); + +describe("assembleEntries", () => { + it("assigns sequential ranks from startRank", () => { + const entries = assembleEntries(["a", "b", "c"], [meta("a", 90), meta("b", 85), meta("c", 80)], 1); + expect(entries.map((e) => [e.rank, e.login])).toEqual([[1, "a"], [2, "b"], [3, "c"]]); + }); + + it("starts ranks at an arbitrary offset (page 2)", () => { + const entries = assembleEntries(["k", "l"], [meta("k", 70), meta("l", 69)], 21); + expect(entries.map((e) => e.rank)).toEqual([21, 22]); + }); + + it("skips members with missing meta but keeps ranks aligned to position", () => { + const entries = assembleEntries(["a", "b", "c"], [meta("a", 90), null, meta("c", 80)], 1); + expect(entries.map((e) => [e.rank, e.login])).toEqual([[1, "a"], [3, "c"]]); + }); +}); + +describe("pageBounds", () => { + it("computes inclusive ZREVRANGE bounds for page 1", () => { + expect(pageBounds(1, 20, 100)).toEqual({ start: 0, stop: 19, page: 1, totalPages: 5 }); + }); + it("computes bounds for a middle page", () => { + expect(pageBounds(3, 20, 100)).toMatchObject({ start: 40, stop: 59, page: 3 }); + }); + it("clamps an out-of-range page to the last page", () => { + expect(pageBounds(99, 20, 100)).toMatchObject({ page: 5, start: 80, stop: 99 }); + }); + it("clamps a non-positive / NaN page to 1", () => { + expect(pageBounds(0, 20, 100)).toMatchObject({ page: 1 }); + expect(pageBounds(NaN, 20, 100)).toMatchObject({ page: 1 }); + }); + it("reports at least one page when the board is empty", () => { + expect(pageBounds(1, 20, 0)).toMatchObject({ page: 1, totalPages: 1, start: 0, stop: 19 }); + }); +}); + +describe("neighborBounds", () => { + it("windows ±span around a middle rank", () => { + expect(neighborBounds(50, 3, 100)).toEqual({ start: 47, stop: 53 }); + }); + it("clamps at the top edge", () => { + expect(neighborBounds(1, 3, 100)).toEqual({ start: 0, stop: 4 }); + }); + it("clamps at the bottom edge", () => { + expect(neighborBounds(99, 3, 100)).toEqual({ start: 96, stop: 99 }); + }); +}); + +describe("sampleSeedEntries", () => { + it("ranks the baked sample cards by overall desc with sequential ranks", () => { + const seed = sampleSeedEntries(); + expect(seed.length).toBeGreaterThan(0); + expect(seed[0].rank).toBe(1); + for (let i = 1; i < seed.length; i++) { + expect(seed[i - 1].overall).toBeGreaterThanOrEqual(seed[i].overall); + expect(seed[i].rank).toBe(i + 1); + } + }); +}); From 848eff1afec78cb64932072795eacfcb044cabc1 Mon Sep 17 00:00:00 2001 From: cherchali mohamed walid Date: Sun, 12 Jul 2026 13:35:09 +0100 Subject: [PATCH 2/6] feat(leaderboard): best-effort Redis read/write layer 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. --- lib/leaderboard.ts | 114 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 lib/leaderboard.ts diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts new file mode 100644 index 0000000..38db7cb --- /dev/null +++ b/lib/leaderboard.ts @@ -0,0 +1,114 @@ +import "server-only"; +import { redis } from "./redis"; +import type { Card } from "./scoring/types"; +import { + type LeaderboardEntry, + type MetaEntry, + serializeMeta, + parseMeta, + assembleEntries, + pageBounds, + neighborBounds, + sampleSeedEntries, +} from "./leaderboard-core"; + +// Sorted set = the ranking (score = overall); hash = per-login display fields. +// Versioned so a meta-shape change can invalidate the board by bumping v1. +const ZKEY = "gitfut:leaderboard:v1"; +const METAKEY = "gitfut:leaderboard:meta:v1"; +const DEFAULT_PAGE_SIZE = 25; + +const norm = (u: string) => u.trim().replace(/^@/, "").toLowerCase(); + +// Add / update a card on the board. Best-effort: never throws, no-ops with no +// Redis. The ZSET member and hash field are the normalized login; the stored +// meta keeps the card's display-case login for the row link. +export async function recordLeaderboardEntry(card: Card): Promise { + if (!redis) return; + const login = norm(card.login); + try { + await Promise.all([ + redis.zadd(ZKEY, card.overall, login), + redis.hset(METAKEY, login, serializeMeta(card)), + ]); + } catch (e) { + console.error("[leaderboard] record failed:", (e as Error).message); + } +} + +async function metasFor(logins: string[]): Promise<(MetaEntry | null)[]> { + if (logins.length === 0 || !redis) return logins.map(() => null); + const raw = await redis.hmget(METAKEY, ...logins); + return raw.map(parseMeta); +} + +// One page of the board, newest ranking first. Falls back to the sample seed +// when Redis is off, empty, or errors. +export async function getLeaderboardPage( + page: number, + size: number = DEFAULT_PAGE_SIZE, +): Promise<{ entries: LeaderboardEntry[]; total: number; page: number; totalPages: number }> { + if (redis) { + try { + const total = await redis.zcard(ZKEY); + if (total > 0) { + const { start, stop, page: clamped, totalPages } = pageBounds(page, size, total); + const members = await redis.zrevrange(ZKEY, start, stop); + const metas = await metasFor(members); + return { entries: assembleEntries(members, metas, start + 1), total, page: clamped, totalPages }; + } + } catch (e) { + console.error("[leaderboard] page read failed:", (e as Error).message); + } + } + return seedPage(page, size); +} + +function seedPage(page: number, size: number) { + const all = sampleSeedEntries(); + const { start, stop, page: clamped, totalPages } = pageBounds(page, size, all.length); + return { entries: all.slice(start, stop + 1), total: all.length, page: clamped, totalPages }; +} + +// A single login's rank + row, or null if they're on neither the board nor the seed. +export async function getRankFor( + login: string, +): Promise<{ rank: number; total: number; entry: LeaderboardEntry } | null> { + const key = norm(login); + if (redis) { + try { + const [rankIdx, total] = await Promise.all([redis.zrevrank(ZKEY, key), redis.zcard(ZKEY)]); + if (rankIdx != null) { + const m = parseMeta(await redis.hget(METAKEY, key)); + if (m) return { rank: rankIdx + 1, total, entry: { rank: rankIdx + 1, ...m } }; + } + } catch (e) { + console.error("[leaderboard] rank read failed:", (e as Error).message); + } + } + const seed = sampleSeedEntries(); + const idx = seed.findIndex((e) => e.login.toLowerCase() === key); + return idx >= 0 ? { rank: idx + 1, total: seed.length, entry: seed[idx] } : null; +} + +// The rows immediately above and below a login (±span). Empty when they're not ranked. +export async function getNeighbors(login: string, span: number): Promise { + const key = norm(login); + if (redis) { + try { + const [rankIdx, total] = await Promise.all([redis.zrevrank(ZKEY, key), redis.zcard(ZKEY)]); + if (rankIdx != null && total > 0) { + const { start, stop } = neighborBounds(rankIdx, span, total); + const members = await redis.zrevrange(ZKEY, start, stop); + return assembleEntries(members, await metasFor(members), start + 1); + } + } catch (e) { + console.error("[leaderboard] neighbors read failed:", (e as Error).message); + } + } + const seed = sampleSeedEntries(); + const idx = seed.findIndex((e) => e.login.toLowerCase() === key); + if (idx < 0) return []; + const { start, stop } = neighborBounds(idx, span, seed.length); + return seed.slice(start, stop + 1); +} From f19b1b5c9f9e9903cfdb6eaff550a2e350b373bb Mon Sep 17 00:00:00 2001 From: cherchali mohamed walid Date: Sun, 12 Jul 2026 13:35:39 +0100 Subject: [PATCH 3/6] feat(leaderboard): record every scout onto the board 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. --- app/[username]/vs/[opponent]/page.tsx | 12 +++++++++++- app/api/card/[username]/route.ts | 3 ++- app/u/[username]/page.tsx | 6 +++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/[username]/vs/[opponent]/page.tsx b/app/[username]/vs/[opponent]/page.tsx index 4f45375..bcd27de 100644 --- a/app/[username]/vs/[opponent]/page.tsx +++ b/app/[username]/vs/[opponent]/page.tsx @@ -8,6 +8,7 @@ import { loadCard } from "@/lib/scout"; import { getRepoStars } from "@/lib/github/stars"; import { pickFlag } from "@/lib/flagPriority"; import { recordScout } from "@/lib/analytics"; +import { recordLeaderboardEntry } from "@/lib/leaderboard"; import { computeDuel } from "@/lib/duel"; import type { Card } from "@/lib/scoring/types"; @@ -118,7 +119,16 @@ export default async function Page({ params }: Params) { ); } - after(() => Promise.all([recordScout(), recordScout()])); // a duel is two scouts + // a duel is two scouts — record both, and add both to the leaderboard. + // Returned so after()/waitUntil keeps the Redis writes alive until they settle. + after(() => + Promise.all([ + recordScout(), + recordScout(), + recordLeaderboardEntry(a.card), + recordLeaderboardEntry(b.card), + ]), + ); const duel = computeDuel(withFlag(a.card), withFlag(b.card)); return ( diff --git a/app/api/card/[username]/route.ts b/app/api/card/[username]/route.ts index 983d656..107f410 100644 --- a/app/api/card/[username]/route.ts +++ b/app/api/card/[username]/route.ts @@ -2,6 +2,7 @@ import { type GithubError } from "@/lib/github/client"; import { scoutCard } from "@/lib/scout"; import { pickFlag } from "@/lib/flagPriority"; import { recordScout } from "@/lib/analytics"; +import { recordLeaderboardEntry } from "@/lib/leaderboard"; import { after } from "next/server"; import type { Card } from "@/lib/scoring/types"; @@ -18,7 +19,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ username // just resolve the visitor's flag and record the scout after the response. try { const card = await scoutCard(username); - after(() => recordScout()); + after(() => Promise.all([recordScout(), recordLeaderboardEntry(card)])); return Response.json(resolveCountry(card, override)); } catch (e) { const err = e as GithubError; diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx index 83f177b..bad2f1c 100644 --- a/app/u/[username]/page.tsx +++ b/app/u/[username]/page.tsx @@ -7,6 +7,7 @@ import { loadCard } from "@/lib/scout"; import { getRepoStars } from "@/lib/github/stars"; import { pickFlag } from "@/lib/flagPriority"; import { recordScout } from "@/lib/analytics"; +import { recordLeaderboardEntry } from "@/lib/leaderboard"; import type { Card } from "@/lib/scoring/types"; import ScoutRoute from "./ScoutRoute"; @@ -72,7 +73,10 @@ export default async function Page({ let card: Card | null = "card" in res ? res.card : null; let canonicalCountry = ""; // GitHub-derived flag; share links omit ?country= unless overridden if (card) { - after(() => recordScout()); // analytics, flushed after the response (serverless-safe) + const scouted = card; // canonical card, before the display-flag override below + // analytics + leaderboard, flushed after the response (serverless-safe). + // Returned so after()/waitUntil keeps the Redis writes alive until they settle. + after(() => Promise.all([recordScout(), recordLeaderboardEntry(scouted)])); canonicalCountry = pickFlag(null, card.country) ?? ""; // GitHub-derived only const displayCountry = pickFlag(override, card.country) ?? ""; card = { ...card, country: displayCountry }; From 8906be5533187553eee4ed3f2f60cc82f4858b62 Mon Sep 17 00:00:00 2001 From: cherchali mohamed walid Date: Sun, 12 Jul 2026 13:35:49 +0100 Subject: [PATCH 4/6] feat(leaderboard): ranked page with search, pagination & neighbours 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. --- app/leaderboard/page.tsx | 117 +++++++++++++++++++++++++++++++ components/LeaderboardRow.tsx | 54 ++++++++++++++ components/LeaderboardSearch.tsx | 41 +++++++++++ 3 files changed, 212 insertions(+) create mode 100644 app/leaderboard/page.tsx create mode 100644 components/LeaderboardRow.tsx create mode 100644 components/LeaderboardSearch.tsx diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx new file mode 100644 index 0000000..1113f02 --- /dev/null +++ b/app/leaderboard/page.tsx @@ -0,0 +1,117 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import Background from "@/components/Background"; +import LeaderboardRow from "@/components/LeaderboardRow"; +import LeaderboardSearch from "@/components/LeaderboardSearch"; +import { getLeaderboardPage, getRankFor, getNeighbors, recordLeaderboardEntry } from "@/lib/leaderboard"; +import { loadCard } from "@/lib/scout"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Global Leaderboard · GitFut", + description: "Every scouted GitFut card, ranked by overall rating.", +}; + +const PAGE_SIZE = 25; + +export default async function LeaderboardPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string; focus?: string }>; +}) { + 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); + focusBlock = ( +
+
YOUR POSITION
+ {ranked ? ( +

+ @{res.card.login} is ranked{" "} + #{ranked.rank} of {ranked.total.toLocaleString()}. +

+ ) : ( +

@{res.card.login} isn’t ranked yet — try again in a moment.

+ )} +
    + {neighbors.map((e) => ( +
  1. + +
  2. + ))} +
+
+ ); + } else { + focusBlock = ( +

+ Couldn’t scout @{focus} — check the username and try again. +

+ ); + } + } + + return ( +
+ +
+
+
+
GITFUT
+

+ Global Leaderboard +

+
+ + ← HOME + +
+

+ {total.toLocaleString()} card{total === 1 ? "" : "s"} scouted, ranked by overall. +

+ + + {focusBlock} + +
    + {entries.map((e) => ( +
  1. + +
  2. + ))} +
+ + +
+
+ ); +} diff --git a/components/LeaderboardRow.tsx b/components/LeaderboardRow.tsx new file mode 100644 index 0000000..4da8906 --- /dev/null +++ b/components/LeaderboardRow.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; +import type { LeaderboardEntry } from "@/lib/leaderboard-core"; +import { RESULT_THEME } from "@/components/finishTheme"; +import { languageLogoUrl } from "@/lib/github/languages"; + +// One ranked row. Tier ink/chip come from RESULT_THEME so the OVR pill reads in +// the card's own finish colour. `highlight` lifts the searched player's row. +export default function LeaderboardRow({ + entry, + highlight = false, +}: { + entry: LeaderboardEntry; + highlight?: boolean; +}) { + const theme = RESULT_THEME[entry.finish]; + return ( + + + {entry.rank} + + {/* eslint-disable-next-line @next/next/no-img-element */} + + {entry.name} + {entry.country && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + {entry.langSlug && ( + // eslint-disable-next-line @next/next/no-img-element + {entry.topLanguage + )} + + {entry.position} + + + {entry.overall} + + + ); +} diff --git a/components/LeaderboardSearch.tsx b/components/LeaderboardSearch.tsx new file mode 100644 index 0000000..fd53383 --- /dev/null +++ b/components/LeaderboardSearch.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +// Navigates to /leaderboard?focus=. The server then scouts the login (so +// a never-seen user is added to the board) and renders their rank + neighbours. +export default function LeaderboardSearch({ initial = "" }: { initial?: string }) { + const router = useRouter(); + const [value, setValue] = useState(initial); + const [isPending, startTransition] = useTransition(); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const login = value.trim().replace(/^@/, ""); + if (!login) return; + startTransition(() => router.push(`/leaderboard?focus=${encodeURIComponent(login)}`)); + }; + + return ( +
+ setValue(e.target.value)} + placeholder="find your rank — github username" + aria-label="GitHub username" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="h-11 flex-1 rounded-xl border border-white/12 bg-white/[.04] px-4 text-[15px] text-ink outline-none transition focus:border-brand/60" + /> + +
+ ); +} From 510f68e17c4e48641136c45f734194466a220b12 Mon Sep 17 00:00:00 2001 From: cherchali mohamed walid Date: Sun, 12 Jul 2026 13:36:01 +0100 Subject: [PATCH 5/6] feat(leaderboard): nav links from home & report pages 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. --- app/u/[username]/page.tsx | 10 +++++++++- components/AppShell.tsx | 9 ++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx index bad2f1c..dc70cdb 100644 --- a/app/u/[username]/page.tsx +++ b/app/u/[username]/page.tsx @@ -85,7 +85,15 @@ export default async function Page({
{card ? ( - + <> + + LEADERBOARD + + + ) : ( )} diff --git a/components/AppShell.tsx b/components/AppShell.tsx index b89aaa6..4921ae9 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import ScoutForm from "@/components/ScoutForm"; import CardFan from "@/components/CardFan"; import LoadingScreen from "@/components/LoadingScreen"; @@ -57,7 +58,13 @@ export default function AppShell({
{/* Overlaid in the corner (not a flow header) so it never pushes the vertically-centered hero down. */} -
+
+ + LEADERBOARD +
From 7a7631636c89827b4fae005da2ec0d5195a1a905 Mon Sep 17 00:00:00 2001 From: cherchali mohamed walid Date: Sun, 12 Jul 2026 19:21:07 +0100 Subject: [PATCH 6/6] fix(leaderboard): validate full MetaEntry shape in parseMeta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 so the compiler enforces every finish/position variant is listed. Adds tests for the partial-JSON and invalid-enum cases. --- lib/leaderboard-core.ts | 59 +++++++++++++++++++++++++++++++++++---- tests/leaderboard.test.ts | 28 +++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/lib/leaderboard-core.ts b/lib/leaderboard-core.ts index 5ae20f6..2d06fd4 100644 --- a/lib/leaderboard-core.ts +++ b/lib/leaderboard-core.ts @@ -39,17 +39,66 @@ export function cardToMeta(card: Card): MetaEntry { export const serializeMeta = (card: Card): string => JSON.stringify(cardToMeta(card)); -// Tolerant parse: any bad/missing/legacy JSON yields null so the row is skipped -// rather than crashing the page (best-effort, mirrors lib/scout.ts readCache). +// Runtime allow-lists for the enum fields, declared as Record so the +// compiler forces every Finish/Position variant to be listed here (add a new +// finish to the type and this stops compiling until it's added). isFinish / +// isPosition then narrow an unknown value against them. +const FINISH_VALUES: Record = { + bronze: true, + silver: true, + gold: true, + totw: true, + toty: true, + icon: true, + founder: true, +}; +const POSITION_VALUES: Record = { + ST: true, + RW: true, + CAM: true, + CM: true, + CDM: true, + CB: true, +}; +const has = (o: object, v: unknown): boolean => typeof v === "string" && Object.prototype.hasOwnProperty.call(o, v); +const isFinish = (v: unknown): v is Finish => has(FINISH_VALUES, v); +const isPosition = (v: unknown): v is Position => has(POSITION_VALUES, v); + +// Full-shape guard: every MetaEntry field must be present and the right type, and +// finish/position must be real enum values. Downstream consumers (e.g. the row's +// RESULT_THEME[finish] lookup) assume a complete, valid record, so a partial or +// legacy blob must be rejected, not passed through. +function isMetaEntry(v: unknown): v is MetaEntry { + if (typeof v !== "object" || v === null) return false; + const m = v as Record; + return ( + typeof m.login === "string" && + typeof m.name === "string" && + typeof m.avatarUrl === "string" && + typeof m.overall === "number" && + Number.isFinite(m.overall) && + isFinish(m.finish) && + typeof m.finishLabel === "string" && + isPosition(m.position) && + typeof m.country === "string" && + (m.topLanguage === null || typeof m.topLanguage === "string") && + (m.langSlug === null || typeof m.langSlug === "string") + ); +} + +// Tolerant parse: any bad/missing/legacy/corrupt JSON yields null so the row is +// skipped rather than crashing the page (best-effort, mirrors lib/scout.ts +// readCache). Validates the FULL MetaEntry shape — a partially-populated blob like +// { login, overall } is rejected, since consumers assume a complete record. export function parseMeta(json: string | null): MetaEntry | null { if (!json) return null; + let raw: unknown; try { - const m = JSON.parse(json) as MetaEntry; - if (!m || typeof m.login !== "string" || typeof m.overall !== "number") return null; - return m; + raw = JSON.parse(json); } catch { return null; } + return isMetaEntry(raw) ? raw : null; } // Zip parallel members + metas into ranked entries. rank is tied to the member's diff --git a/tests/leaderboard.test.ts b/tests/leaderboard.test.ts index 6c76d55..e4305ec 100644 --- a/tests/leaderboard.test.ts +++ b/tests/leaderboard.test.ts @@ -61,6 +61,34 @@ describe("cardToMeta / serialize / parse", () => { expect(parseMeta("not json")).toBeNull(); expect(parseMeta("{}")).toBeNull(); }); + + it("parseMeta rejects partial/legacy JSON missing required fields", () => { + // The exact review case: enough to pass a login+overall check, but incomplete + // — must be rejected so consumers never see a MetaEntry with an undefined + // finish/position (which would crash RESULT_THEME[finish].chip downstream). + expect(parseMeta(JSON.stringify({ login: "x", overall: 1 }))).toBeNull(); + expect(parseMeta(JSON.stringify(cardToMeta(mkCard("x", 70))))).not.toBeNull(); + }); + + it("parseMeta rejects invalid enum values for finish / position", () => { + const good = cardToMeta(mkCard("x", 70)); + expect(parseMeta(JSON.stringify({ ...good, finish: "platinum" }))).toBeNull(); + expect(parseMeta(JSON.stringify({ ...good, position: "GK" }))).toBeNull(); + }); + + it("parseMeta rejects wrong field types", () => { + const good = cardToMeta(mkCard("x", 70)); + expect(parseMeta(JSON.stringify({ ...good, overall: "70" }))).toBeNull(); + expect(parseMeta(JSON.stringify({ ...good, name: 5 }))).toBeNull(); + expect(parseMeta(JSON.stringify({ ...good, topLanguage: 3 }))).toBeNull(); + const { country: _country, ...noCountry } = good; + expect(parseMeta(JSON.stringify(noCountry))).toBeNull(); + }); + + it("parseMeta accepts a fully-valid record incl. null topLanguage/langSlug", () => { + const m = cardToMeta(mkCard("x", 70, { languageLogo: null, topLanguage: null })); + expect(parseMeta(JSON.stringify(m))).toEqual(m); + }); }); describe("assembleEntries", () => {