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/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) => (
+ -
+
+
+ ))}
+
+
+ );
+ } 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) => (
+ -
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx
index 83f177b..dc70cdb 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 };
@@ -81,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
+
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.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 (
+
+ );
+}
diff --git a/lib/leaderboard-core.ts b/lib/leaderboard-core.ts
new file mode 100644
index 0000000..2d06fd4
--- /dev/null
+++ b/lib/leaderboard-core.ts
@@ -0,0 +1,151 @@
+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));
+
+// 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 {
+ 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
+// 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/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);
+}
diff --git a/tests/leaderboard.test.ts b/tests/leaderboard.test.ts
new file mode 100644
index 0000000..e4305ec
--- /dev/null
+++ b/tests/leaderboard.test.ts
@@ -0,0 +1,152 @@
+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();
+ });
+
+ 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", () => {
+ 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);
+ }
+ });
+});