leaderboard updated

This commit is contained in:
2026-05-12 13:59:12 +05:30
parent 13d4414c37
commit e5414a9920
4 changed files with 263 additions and 52 deletions
+186 -11
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id"; import { ClickableUserId } from "@/components/clickable-user-id";
import { AdminLedger } from "@/components/admin-ledger"; import { AdminLedger } from "@/components/admin-ledger";
import { MatchHistoryBattleCard } from "@/components/match-history-battle-card"; import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle } from "@/lib/dashboard-stats"; import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal"; import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import { import {
buildDashboardHref, buildDashboardHref,
@@ -80,6 +81,117 @@ function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
} }
} }
type LeaderboardSortKey = "rank" | "player" | "rc" | "winRate" | "wins";
function playerSortKey(row: LeaderboardRow): string {
const t = row.username?.trim();
if (t) return t.toLowerCase();
return "\uffff";
}
function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" {
if (key === "player" || key === "rank") return "asc";
return "desc";
}
function sortLeaderboardRows(
rows: LeaderboardRow[],
key: LeaderboardSortKey,
dir: "asc" | "desc",
): LeaderboardRow[] {
const out = [...rows];
out.sort((a, b) => {
let cmp = 0;
switch (key) {
case "rank":
cmp = a.winsLeaderboardRank - b.winsLeaderboardRank;
break;
case "wins":
cmp = a.matchesWon - b.matchesWon;
break;
case "player": {
const ka = playerSortKey(a);
const kb = playerSortKey(b);
cmp = ka.localeCompare(kb, "en", { sensitivity: "base" });
if (cmp === 0) cmp = a.userId - b.userId;
break;
}
case "rc": {
const aOk = a.rcBalance != null && Number.isFinite(a.rcBalance);
const bOk = b.rcBalance != null && Number.isFinite(b.rcBalance);
if (!aOk && !bOk) cmp = 0;
else if (!aOk) cmp = 1;
else if (!bOk) cmp = -1;
else cmp = a.rcBalance! - b.rcBalance!;
break;
}
case "winRate": {
const aOk =
a.winRatePercent != null && Number.isFinite(a.winRatePercent);
const bOk =
b.winRatePercent != null && Number.isFinite(b.winRatePercent);
if (!aOk && !bOk) cmp = 0;
else if (!aOk) cmp = 1;
else if (!bOk) cmp = -1;
else cmp = a.winRatePercent! - b.winRatePercent!;
break;
}
default:
break;
}
if (cmp !== 0) return dir === "asc" ? cmp : -cmp;
return a.userId - b.userId;
});
return out;
}
function LeaderboardSortTh({
label,
sortKey,
activeKey,
activeDir,
onActivate,
align = "left",
}: {
label: string;
sortKey: LeaderboardSortKey;
activeKey: LeaderboardSortKey;
activeDir: "asc" | "desc";
onActivate: (key: LeaderboardSortKey) => void;
align?: "left" | "right";
}) {
const active = activeKey === sortKey;
const rowAlign =
align === "right" ? "text-right" : "text-left";
const btnAlign =
align === "right"
? "justify-end text-right"
: "justify-start text-left";
return (
<th
scope="col"
className={`px-4 py-3 font-medium ${rowAlign}`}
aria-sort={
active ? (activeDir === "asc" ? "ascending" : "descending") : "none"
}
>
<button
type="button"
aria-label={`Sort by ${label}`}
className={`-mx-1 flex w-full items-center gap-1 rounded px-1 py-0.5 text-inherit hover:bg-zinc-200/80 dark:hover:bg-zinc-700/80 ${btnAlign}`}
onClick={() => onActivate(sortKey)}
>
<span>{label}</span>
{active ? (
<span className="shrink-0 tabular-nums text-zinc-400" aria-hidden>
{activeDir === "asc" ? "↑" : "↓"}
</span>
) : null}
</button>
</th>
);
}
type Props = { type Props = {
users: DbUser[]; users: DbUser[];
matches: AdminMatchRow[]; matches: AdminMatchRow[];
@@ -159,6 +271,26 @@ export function AdminDashboard({
matchmakerError, matchmakerError,
}: Props) { }: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true); const [hideNoWinner, setHideNoWinner] = useState(true);
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
const lbRaw = statsBundle?.leaderboard;
const sortedLeaderboard = useMemo(() => {
const rows = lbRaw ?? [];
if (rows.length === 0) return [];
return sortLeaderboardRows(rows, lbSortKey, lbSortDir);
}, [lbRaw, lbSortKey, lbSortDir]);
const onLbSort = useCallback((key: LeaderboardSortKey) => {
setLbSortKey((prevKey) => {
if (key === prevKey) {
setLbSortDir((d) => (d === "asc" ? "desc" : "asc"));
return prevKey;
}
setLbSortDir(leaderboardDefaultSortDir(key));
return key;
});
}, []);
const userById = useMemo(() => { const userById = useMemo(() => {
const m = new Map<number, DbUser>(); const m = new Map<number, DbUser>();
@@ -369,31 +501,63 @@ export function AdminDashboard({
<div> <div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400"> <h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Top players by match winnings Top players by match wins
</h2> </h2>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900"> <div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm"> <table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400"> <thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr> <tr>
<th className="px-4 py-3 font-medium">#</th> <LeaderboardSortTh
<th className="px-4 py-3 font-medium">Player</th> label="#"
<th className="px-4 py-3 font-medium text-right"> sortKey="rank"
Total prize CC activeKey={lbSortKey}
</th> activeDir={lbSortDir}
onActivate={onLbSort}
/>
<LeaderboardSortTh
label="Player"
sortKey="player"
activeKey={lbSortKey}
activeDir={lbSortDir}
onActivate={onLbSort}
/>
<LeaderboardSortTh
label="RC balance"
sortKey="rc"
activeKey={lbSortKey}
activeDir={lbSortDir}
onActivate={onLbSort}
/>
<LeaderboardSortTh
label="Win rate"
sortKey="winRate"
activeKey={lbSortKey}
activeDir={lbSortDir}
onActivate={onLbSort}
align="right"
/>
<LeaderboardSortTh
label="Wins"
sortKey="wins"
activeKey={lbSortKey}
activeDir={lbSortDir}
onActivate={onLbSort}
align="right"
/>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800"> <tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{(statsBundle?.leaderboard ?? []).length === 0 ? ( {(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr> <tr>
<td <td
colSpan={3} colSpan={5}
className="px-4 py-8 text-center text-zinc-500" className="px-4 py-8 text-center text-zinc-500"
> >
No wins recorded yet (no rows with a winner). No wins recorded yet (no rows with a winner).
</td> </td>
</tr> </tr>
) : ( ) : (
(statsBundle?.leaderboard ?? []).map((row, i) => ( sortedLeaderboard.map((row, i) => (
<tr <tr
key={row.userId} key={row.userId}
className="text-zinc-800 dark:text-zinc-200" className="text-zinc-800 dark:text-zinc-200"
@@ -414,8 +578,19 @@ export function AdminDashboard({
)} )}
</span> </span>
</td> </td>
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
{formatRcBalanceWithCoins(row.rcBalance)}
</td>
<td className="px-4 py-2 text-right font-mono tabular-nums"> <td className="px-4 py-2 text-right font-mono tabular-nums">
{row.totalPrizeCc.toLocaleString("en-US")} {row.winRatePercent == null
? "—"
: `${row.winRatePercent.toLocaleString("en-US", {
maximumFractionDigits: 1,
minimumFractionDigits: 0,
})}%`}
</td>
<td className="px-4 py-2 text-right font-mono tabular-nums">
{row.matchesWon.toLocaleString("en-US")}
</td> </td>
</tr> </tr>
)) ))
+1 -12
View File
@@ -13,8 +13,8 @@ import type {
PlayerCardMatchHistoryRow, PlayerCardMatchHistoryRow,
} from "@/lib/player-card-server"; } from "@/lib/player-card-server";
import { import {
formatRcBalanceWithCoins,
formatRcLabelFromCoinsBigInt, formatRcLabelFromCoinsBigInt,
rcToCoins,
} from "@/lib/coins-rc"; } from "@/lib/coins-rc";
import { usePlayerCard } from "@/components/player-card-context"; import { usePlayerCard } from "@/components/player-card-context";
@@ -42,17 +42,6 @@ function formatCcBalance(cc: number | null): string {
return cc.toLocaleString("en-US"); return cc.toLocaleString("en-US");
} }
/** `users.rc` is stored as display RC; show ledger-style RC plus coin units in brackets. */
function formatRcBalanceWithCoins(rc: number | null): string {
if (rc == null || !Number.isFinite(rc)) return "—";
const coins = rcToCoins(rc);
if (coins != null) {
const rcLabel = formatRcLabelFromCoinsBigInt(BigInt(coins));
return `${rcLabel} (${coins.toLocaleString("en-US")})`;
}
return `${rc.toFixed(1)} RC`;
}
function outcomeClass(outcome: PlayerCardMatchHistoryRow["outcomeLabel"]): string { function outcomeClass(outcome: PlayerCardMatchHistoryRow["outcomeLabel"]): string {
if (outcome === "Win") { if (outcome === "Win") {
return "font-semibold text-emerald-700 dark:text-emerald-400"; return "font-semibold text-emerald-700 dark:text-emerald-400";
+11
View File
@@ -59,3 +59,14 @@ export function formatSignedRcFromCoinsBigInt(coins: bigint): string {
const s = `${whole.toString()}.${tenths.toString()}`; const s = `${whole.toString()}.${tenths.toString()}`;
return neg ? `\u2212${s}` : s; return neg ? `\u2212${s}` : s;
} }
/** `users.rc` is stored as display RC; show ledger-style RC plus coin units in brackets. */
export function formatRcBalanceWithCoins(rc: number | null): string {
if (rc == null || !Number.isFinite(rc)) return "—";
const coins = rcToCoins(rc);
if (coins != null) {
const rcLabel = formatRcLabelFromCoinsBigInt(BigInt(coins));
return `${rcLabel} (${coins.toLocaleString("en-US")})`;
}
return `${rc.toFixed(1)} RC`;
}
+63 -27
View File
@@ -14,7 +14,12 @@ export type DashboardStatsSnapshot = {
export type LeaderboardRow = { export type LeaderboardRow = {
userId: number; userId: number;
username: string | null; username: string | null;
totalPrizeCc: number; /** Matches with `winner_id` equal to this user (same as player card). */
matchesWon: number;
rcBalance: number | null;
winRatePercent: number | null;
/** 1 = most wins in this snapshot (stable order for the “#” column sort). */
winsLeaderboardRank: number;
}; };
export type DashboardStatsBundle = { export type DashboardStatsBundle = {
@@ -54,17 +59,17 @@ function pickCount(
return { value: res.count ?? 0, err: null }; return { value: res.count ?? 0, err: null };
} }
async function fetchTopWinnersByPrizeCc( async function fetchTopPlayersByMatchWins(
supabase: SupabaseClient, supabase: SupabaseClient,
): Promise<{ rows: LeaderboardRow[]; error: string | null }> { ): Promise<{ rows: LeaderboardRow[]; error: string | null }> {
const winnings = new Map<number, number>(); const winCounts = new Map<number, number>();
const pageSize = 1000; const pageSize = 1000;
let offset = 0; let offset = 0;
for (;;) { for (;;) {
const { data, error } = await supabase const { data, error } = await supabase
.from("matches") .from("matches")
.select("winner_id, prize_cc") .select("winner_id")
.not("winner_id", "is", null) .not("winner_id", "is", null)
.range(offset, offset + pageSize - 1); .range(offset, offset + pageSize - 1);
@@ -75,22 +80,15 @@ async function fetchTopWinnersByPrizeCc(
const batch = data ?? []; const batch = data ?? [];
for (const row of batch) { for (const row of batch) {
const wid = row.winner_id as number; const wid = row.winner_id as number;
const raw = row.prize_cc; if (!Number.isFinite(wid) || wid < 1) continue;
const prize = winCounts.set(wid, (winCounts.get(wid) ?? 0) + 1);
typeof raw === "number"
? raw
: raw != null
? Number(raw)
: 0;
const add = Number.isFinite(prize) ? prize : 0;
winnings.set(wid, (winnings.get(wid) ?? 0) + add);
} }
if (batch.length < pageSize) break; if (batch.length < pageSize) break;
offset += pageSize; offset += pageSize;
} }
const topPairs = [...winnings.entries()] const topPairs = [...winCounts.entries()]
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 10); .slice(0, 10);
@@ -99,27 +97,65 @@ async function fetchTopWinnersByPrizeCc(
} }
const ids = topPairs.map(([id]) => id); const ids = topPairs.map(([id]) => id);
const { data: userRows, error: userErr } = await supabase const playedPromises = ids.map((userId) =>
.from("users") supabase
.select("id, username") .from("matches")
.in("id", ids); .select("id", { count: "exact", head: true })
.or(`user_red.eq.${userId},user_blue.eq.${userId}`),
);
if (userErr) { const [usersRes, ...playedRes] = await Promise.all([
return { rows: [], error: userErr.message }; supabase.from("users").select("id, username, rc").in("id", ids),
...playedPromises,
]);
if (usersRes.error) {
return { rows: [], error: usersRes.error.message };
} }
const nameById = new Map<number, string | null>(); const nameById = new Map<number, string | null>();
for (const u of userRows ?? []) { const rcById = new Map<number, number | null>();
nameById.set(u.id as number, (u.username as string | null) ?? null); for (const u of usersRes.data ?? []) {
const uid = u.id as number;
nameById.set(uid, (u.username as string | null) ?? null);
const rawRc = u.rc as number | null;
rcById.set(
uid,
rawRc != null && Number.isFinite(rawRc) ? rawRc : null,
);
} }
const rows: LeaderboardRow[] = topPairs.map(([userId, totalPrizeCc]) => ({ const playedErrs: string[] = [];
const playedById = new Map<number, number>();
for (let i = 0; i < ids.length; i++) {
const res = playedRes[i];
const uid = ids[i]!;
if (res.error) {
playedErrs.push(`${uid}: ${res.error.message}`);
continue;
}
playedById.set(uid, res.count ?? 0);
}
const rows: LeaderboardRow[] = topPairs.map(([userId, matchesWon], index) => {
const played = playedById.get(userId);
const winRatePercent =
played != null && played > 0
? Math.round((matchesWon / played) * 1000) / 10
: null;
return {
userId, userId,
username: nameById.get(userId) ?? null, username: nameById.get(userId) ?? null,
totalPrizeCc, matchesWon,
})); rcBalance: rcById.get(userId) ?? null,
winRatePercent,
winsLeaderboardRank: index + 1,
};
});
return { rows, error: null }; const error =
playedErrs.length > 0 ? `played counts: ${playedErrs.join("; ")}` : null;
return { rows, error };
} }
/** Aggregates counts and leaderboard for the admin overview tab. Server-only. */ /** Aggregates counts and leaderboard for the admin overview tab. Server-only. */
@@ -197,7 +233,7 @@ export async function loadDashboardStatsBundle(
: null; : null;
const { rows: leaderboard, error: lbErr } = const { rows: leaderboard, error: lbErr } =
await fetchTopWinnersByPrizeCc(supabase); await fetchTopPlayersByMatchWins(supabase);
const errorMessages = [...errs, ...(lbErr ? [`leaderboard: ${lbErr}`] : [])]; const errorMessages = [...errs, ...(lbErr ? [`leaderboard: ${lbErr}`] : [])];
const error = errorMessages.length > 0 ? errorMessages.join(" · ") : null; const error = errorMessages.length > 0 ? errorMessages.join(" · ") : null;