diff --git a/app/api/leaderboard/route.ts b/app/api/leaderboard/route.ts new file mode 100644 index 0000000..5920319 --- /dev/null +++ b/app/api/leaderboard/route.ts @@ -0,0 +1,21 @@ +import { getLeaderboard } from "@/lib/leaderboard"; + +// Global top-N by OVR. CDN-cacheable and short-TTL like /api/contributors — +// the ranking shifts as people get scouted, but not fast enough to need a +// fresh Redis hit on every single request. +export const runtime = "nodejs"; + +export async function GET(req: Request) { + const raw = Number(new URL(req.url).searchParams.get("limit")); + const limit = Number.isFinite(raw) && raw > 0 ? Math.min(raw, 200) : 100; + + const entries = await getLeaderboard(limit); + return Response.json( + { entries }, + { + headers: { + "Cache-Control": "public, max-age=30, s-maxage=60, stale-while-revalidate=300", + }, + }, + ); +} \ No newline at end of file diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx new file mode 100644 index 0000000..d8ad7a3 --- /dev/null +++ b/app/leaderboard/page.tsx @@ -0,0 +1,257 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { getLeaderboard } from "@/lib/leaderboard"; +import { RESULT_THEME } from "@/components/finishTheme"; + +// Derived from getLeaderboard's return type rather than importing a named +// `LeaderboardEntry` export, since lib/leaderboard.ts wasn't in scope here — +// swap this for a direct import if that type is already exported there. +type LeaderboardEntry = Awaited>[number]; + +// Dynamic so a freshly-scouted card can show up without waiting on a static +// revalidation window — the underlying Redis read is cheap. +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Global Leaderboard — GitFut", + description: "Every GitHub profile scouted on GitFut, ranked by OVR.", +}; + +const POSITION_LABEL: Record = { + ST: "Striker", + RW: "Winger", + CAM: "Attacking Mid", + CM: "Midfielder", + CDM: "Defensive Mid", + CB: "Center Back", +}; + +// Podium slots are drawn in this visual order (2nd, 1st, 3rd) so rank 1 +// reads as the peak of a three-up TOTW reveal, not a plain ranked list. +const PODIUM_ORDER = [2, 1, 3] as const; + +function Avatar({ + url, + size, + ringColor, +}: { + url: string | null | undefined; + size: number; + ringColor: string; +}) { + return ( +
+ {url ? ( + // eslint-disable-next-line @next/next/no-img-element -- external GitHub avatar host + + ) : ( +
+ )} +
+ ); +} + +function PodiumCard({ entry, delayMs }: { entry: LeaderboardEntry; delayMs: number }) { + const theme = RESULT_THEME[entry.finish]; + const isFirst = entry.rank === 1; + + return ( + + + {entry.rank} + + + + +
+
+ {entry.name || entry.login} +
+
@{entry.login}
+
+ + + {POSITION_LABEL[entry.position] ?? entry.position} + + + + {entry.overall} + + + {entry.country && ( + // eslint-disable-next-line @next/next/no-img-element -- local flag sprite, no need for next/image sizing + + )} + + ); +} + +function Podium({ entries }: { entries: LeaderboardEntry[] }) { + const byRank = new Map(entries.map((e) => [e.rank, e])); + + return ( +
+ {PODIUM_ORDER.map((rank, i) => { + const entry = byRank.get(rank); + if (!entry) return
; + return ; + })} +
+ ); +} + +function RankBadge({ rank, tint }: { rank: number; tint: string }) { + return ( + + {rank} + + ); +} + +export default async function LeaderboardPage() { + const entries = await getLeaderboard(100); + const podium = entries.filter((e) => e.rank <= 3); + const rest = entries.filter((e) => e.rank > 3); + + return ( +
+ {/* Ambient flood background — same motif used on the home page, dimmed + so it reads as atmosphere behind the podium rather than competing + with the tier glows on the cards themselves. */} +
+
+
+
+ +
+ + ← Back to GitFut + + +
+

+ GLOBAL LEADERBOARD +

+

Every profile scouted, ranked by OVR.

+
+ + {entries.length === 0 ? ( +

No scouts yet — be the first.

+ ) : ( + <> + {podium.length > 0 && } + + {rest.length > 0 && ( +
    + {rest.map((e) => { + const theme = RESULT_THEME[e.finish]; + return ( +
  1. + + + + + +
    +
    + {e.name || e.login} +
    +
    @{e.login}
    +
    + + {e.country && ( + // eslint-disable-next-line @next/next/no-img-element -- local flag sprite, no need for next/image sizing + + )} + + + {POSITION_LABEL[e.position] ?? e.position} + + + + {e.overall} + + +
  2. + ); + })} +
+ )} + + )} +
+
+ ); +} \ No newline at end of file diff --git a/components/AppShell.tsx b/components/AppShell.tsx index b89aaa6..faa6b1f 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -11,6 +11,7 @@ import BuyMeACoffee from "@/components/BuyMeACoffee"; import SupportProductHunt from "@/components/SupportProductHunt"; import GithubStar from "@/components/GithubStar"; import { SAMPLE_CARDS } from "@/lib/github/samples"; +import Link from "next/link"; const HowItWorksModal = dynamic(() => import("@/components/HowItWorksModal"), { ssr: false, @@ -57,7 +58,14 @@ export default function AppShell({
{/* Overlaid in the corner (not a flow header) so it never pushes the vertically-centered hero down. */} -
+
+ + Leaderboard + 🏆 +
@@ -82,4 +90,4 @@ export default function AppShell({ ); -} +} \ No newline at end of file diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts new file mode 100644 index 0000000..65a5695 --- /dev/null +++ b/lib/leaderboard.ts @@ -0,0 +1,106 @@ +import "server-only"; +import { redis } from "./redis"; +import type { Card } from "./scoring/types"; + +// Global, all-time leaderboard of every card gitfut has built, ranked by OVR. +// A Redis sorted set (score = overall, member = login) gives O(log N) ranked +// writes and a cheap top-N read; a companion hash carries the small display +// snapshot (name/avatar/country/position/finish) so a leaderboard read never +// has to re-fetch or re-score anyone. Both best-effort, mirroring +// lib/analytics + lib/redis: a missing REDIS_URL, a miss, or an outage just +// means an empty/stale leaderboard — it never throws and never blocks a scout. + +const ZSET_KEY = "gitfut:leaderboard:overall"; +const META_KEY = "gitfut:leaderboard:meta"; + +export interface LeaderboardEntry { + rank: number; + login: string; + name: string; + avatarUrl: string; + country: string; + position: Card["position"]; + finish: Card["finish"]; + overall: number; +} + +type Meta = Pick; + +// Record (or refresh) a card's leaderboard entry. Called once per fresh build +// (lib/scout's buildFresh) — re-scouting the same login within its cache TTL +// doesn't re-write; re-scouting after the TTL expires just overwrites the same +// member's score + meta, so nobody can appear twice. +export async function recordLeaderboardEntry(card: Card): Promise { + if (!redis) return; + const login = card.login.toLowerCase(); + const meta: Meta = { + login: card.login, + name: card.name, + avatarUrl: card.avatarUrl, + country: card.country, + position: card.position, + finish: card.finish, + }; + try { + await Promise.all([ + redis.zadd(ZSET_KEY, card.overall, login), + redis.hset(META_KEY, login, JSON.stringify(meta)), + ]); + } catch (e) { + console.error("[leaderboard] record failed:", (e as Error).message); + } +} + +// Top N by OVR, highest first. Returns [] when Redis is off or empty rather +// than throwing, so the page/route can render a "no scouts yet" state. +export async function getLeaderboard(limit = 100): Promise { + if (!redis) return []; + try { + const ranked = await redis.zrevrange(ZSET_KEY, 0, limit - 1, "WITHSCORES"); + if (ranked.length === 0) return []; + + const logins: string[] = []; + const scores: number[] = []; + for (let i = 0; i < ranked.length; i += 2) { + logins.push(ranked[i]); + scores.push(Number(ranked[i + 1])); + } + + // HMGET preserves the same login order, so meta[i] always matches logins[i]. + const rawMeta = await redis.hmget(META_KEY, ...logins); + + return logins.map((login, i) => { + const parsed: Meta | null = rawMeta[i] ? (JSON.parse(rawMeta[i] as string) as Meta) : null; + return { + rank: i + 1, + login, + overall: scores[i], + name: parsed?.name ?? login, + avatarUrl: parsed?.avatarUrl ?? "", + country: parsed?.country ?? "", + position: parsed?.position ?? "CM", + finish: parsed?.finish ?? "bronze", + }; + }); + } catch (e) { + console.error("[leaderboard] read failed:", (e as Error).message); + return []; + } +} + +// A single scout's rank + OVR — for a "you're #N globally" callout on their +// own card. Null when they've never been scouted or Redis is unavailable. +export async function getLeaderboardRank( + username: string, +): Promise<{ rank: number; overall: number } | null> { + if (!redis) return null; + const login = username.trim().replace(/^@/, "").toLowerCase(); + try { + const [rank, score] = await Promise.all([redis.zrevrank(ZSET_KEY, login), redis.zscore(ZSET_KEY, login)]); + if (rank == null || score == null) return null; + return { rank: rank + 1, overall: Number(score) }; + } catch (e) { + console.error("[leaderboard] rank lookup failed:", (e as Error).message); + return null; + } +} \ No newline at end of file diff --git a/lib/redis.ts b/lib/redis.ts index b1d8ddb..98e24e5 100644 --- a/lib/redis.ts +++ b/lib/redis.ts @@ -22,3 +22,5 @@ function create(): Redis | null { export const redis: Redis | null = globalThis.__gitfutRedis !== undefined ? globalThis.__gitfutRedis : (globalThis.__gitfutRedis = create()); + + console.log("[redis]", redis ? "connected" : "NOT connected — check REDIS_URL"); \ No newline at end of file diff --git a/lib/scout.ts b/lib/scout.ts index 9b631f6..9642a9b 100644 --- a/lib/scout.ts +++ b/lib/scout.ts @@ -6,6 +6,7 @@ import { fetchProfile, type GithubError } from "./github/client"; import { signalsFromPayload } from "./github/signals"; import { SAMPLE_CARDS } from "./github/samples"; import type { Card } from "./scoring/types"; +import { recordLeaderboardEntry } from "./leaderboard"; // Read-through Redis cache for built cards — the single path every scout surface // (the / page, the JSON API, the OG image) uses to turn a username into a @@ -64,6 +65,7 @@ const inflight = new Map>(); async function buildFresh(username: string, login: string): Promise { const card = buildCard(signalsFromPayload(await fetchProfile(username))); await writeCache(login, card); + void recordLeaderboardEntry(card); // fire-and-forget, mirrors recordScout return card; } @@ -107,4 +109,4 @@ export const loadCard = cache( return { error: e as GithubError }; } }, -); +); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index da6978d..77d7521 100644 --- a/package-lock.json +++ b/package-lock.json @@ -621,9 +621,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -640,9 +637,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -659,9 +653,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -678,9 +669,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -697,9 +685,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -716,9 +701,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -735,9 +717,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -754,9 +733,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -773,9 +749,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -798,9 +771,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -823,9 +793,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -848,9 +815,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -873,9 +837,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -898,9 +859,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -923,9 +881,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -948,9 +903,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1172,9 +1124,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1191,9 +1140,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1210,9 +1156,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1229,9 +1172,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1424,9 +1364,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1444,9 +1381,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1464,9 +1398,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1484,9 +1415,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1504,9 +1432,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1524,9 +1449,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1792,9 +1714,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1812,9 +1731,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1832,9 +1748,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1852,9 +1765,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2423,9 +2333,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2440,9 +2347,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2457,9 +2361,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2474,9 +2375,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2491,9 +2389,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2508,9 +2403,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2525,9 +2417,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2542,9 +2431,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2559,9 +2445,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2576,9 +2459,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5530,9 +5410,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5554,9 +5431,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5578,9 +5452,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5602,9 +5473,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [