leaderboard updated
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
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 { AdminLedger } from "@/components/admin-ledger";
|
||||
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 {
|
||||
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 = {
|
||||
users: DbUser[];
|
||||
matches: AdminMatchRow[];
|
||||
@@ -159,6 +271,26 @@ export function AdminDashboard({
|
||||
matchmakerError,
|
||||
}: Props) {
|
||||
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 m = new Map<number, DbUser>();
|
||||
@@ -369,31 +501,63 @@ export function AdminDashboard({
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<th className="px-4 py-3 font-medium">#</th>
|
||||
<th className="px-4 py-3 font-medium">Player</th>
|
||||
<th className="px-4 py-3 font-medium text-right">
|
||||
Total prize CC
|
||||
</th>
|
||||
<LeaderboardSortTh
|
||||
label="#"
|
||||
sortKey="rank"
|
||||
activeKey={lbSortKey}
|
||||
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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
|
||||
{(statsBundle?.leaderboard ?? []).length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={3}
|
||||
colSpan={5}
|
||||
className="px-4 py-8 text-center text-zinc-500"
|
||||
>
|
||||
No wins recorded yet (no rows with a winner).
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
(statsBundle?.leaderboard ?? []).map((row, i) => (
|
||||
sortedLeaderboard.map((row, i) => (
|
||||
<tr
|
||||
key={row.userId}
|
||||
className="text-zinc-800 dark:text-zinc-200"
|
||||
@@ -414,8 +578,19 @@ export function AdminDashboard({
|
||||
)}
|
||||
</span>
|
||||
</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">
|
||||
{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>
|
||||
</tr>
|
||||
))
|
||||
|
||||
@@ -13,8 +13,8 @@ import type {
|
||||
PlayerCardMatchHistoryRow,
|
||||
} from "@/lib/player-card-server";
|
||||
import {
|
||||
formatRcBalanceWithCoins,
|
||||
formatRcLabelFromCoinsBigInt,
|
||||
rcToCoins,
|
||||
} from "@/lib/coins-rc";
|
||||
import { usePlayerCard } from "@/components/player-card-context";
|
||||
|
||||
@@ -42,17 +42,6 @@ function formatCcBalance(cc: number | null): string {
|
||||
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 {
|
||||
if (outcome === "Win") {
|
||||
return "font-semibold text-emerald-700 dark:text-emerald-400";
|
||||
|
||||
@@ -59,3 +59,14 @@ export function formatSignedRcFromCoinsBigInt(coins: bigint): string {
|
||||
const s = `${whole.toString()}.${tenths.toString()}`;
|
||||
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`;
|
||||
}
|
||||
|
||||
+65
-29
@@ -14,7 +14,12 @@ export type DashboardStatsSnapshot = {
|
||||
export type LeaderboardRow = {
|
||||
userId: number;
|
||||
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 = {
|
||||
@@ -54,17 +59,17 @@ function pickCount(
|
||||
return { value: res.count ?? 0, err: null };
|
||||
}
|
||||
|
||||
async function fetchTopWinnersByPrizeCc(
|
||||
async function fetchTopPlayersByMatchWins(
|
||||
supabase: SupabaseClient,
|
||||
): Promise<{ rows: LeaderboardRow[]; error: string | null }> {
|
||||
const winnings = new Map<number, number>();
|
||||
const winCounts = new Map<number, number>();
|
||||
const pageSize = 1000;
|
||||
let offset = 0;
|
||||
|
||||
for (;;) {
|
||||
const { data, error } = await supabase
|
||||
.from("matches")
|
||||
.select("winner_id, prize_cc")
|
||||
.select("winner_id")
|
||||
.not("winner_id", "is", null)
|
||||
.range(offset, offset + pageSize - 1);
|
||||
|
||||
@@ -75,22 +80,15 @@ async function fetchTopWinnersByPrizeCc(
|
||||
const batch = data ?? [];
|
||||
for (const row of batch) {
|
||||
const wid = row.winner_id as number;
|
||||
const raw = row.prize_cc;
|
||||
const prize =
|
||||
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 (!Number.isFinite(wid) || wid < 1) continue;
|
||||
winCounts.set(wid, (winCounts.get(wid) ?? 0) + 1);
|
||||
}
|
||||
|
||||
if (batch.length < pageSize) break;
|
||||
offset += pageSize;
|
||||
}
|
||||
|
||||
const topPairs = [...winnings.entries()]
|
||||
const topPairs = [...winCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10);
|
||||
|
||||
@@ -99,27 +97,65 @@ async function fetchTopWinnersByPrizeCc(
|
||||
}
|
||||
|
||||
const ids = topPairs.map(([id]) => id);
|
||||
const { data: userRows, error: userErr } = await supabase
|
||||
.from("users")
|
||||
.select("id, username")
|
||||
.in("id", ids);
|
||||
const playedPromises = ids.map((userId) =>
|
||||
supabase
|
||||
.from("matches")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.or(`user_red.eq.${userId},user_blue.eq.${userId}`),
|
||||
);
|
||||
|
||||
if (userErr) {
|
||||
return { rows: [], error: userErr.message };
|
||||
const [usersRes, ...playedRes] = await Promise.all([
|
||||
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>();
|
||||
for (const u of userRows ?? []) {
|
||||
nameById.set(u.id as number, (u.username as string | null) ?? null);
|
||||
const rcById = new Map<number, number | 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]) => ({
|
||||
userId,
|
||||
username: nameById.get(userId) ?? null,
|
||||
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);
|
||||
}
|
||||
|
||||
return { rows, error: null };
|
||||
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,
|
||||
username: nameById.get(userId) ?? null,
|
||||
matchesWon,
|
||||
rcBalance: rcById.get(userId) ?? null,
|
||||
winRatePercent,
|
||||
winsLeaderboardRank: index + 1,
|
||||
};
|
||||
});
|
||||
|
||||
const error =
|
||||
playedErrs.length > 0 ? `played counts: ${playedErrs.join("; ")}` : null;
|
||||
return { rows, error };
|
||||
}
|
||||
|
||||
/** Aggregates counts and leaderboard for the admin overview tab. Server-only. */
|
||||
@@ -197,7 +233,7 @@ export async function loadDashboardStatsBundle(
|
||||
: null;
|
||||
|
||||
const { rows: leaderboard, error: lbErr } =
|
||||
await fetchTopWinnersByPrizeCc(supabase);
|
||||
await fetchTopPlayersByMatchWins(supabase);
|
||||
|
||||
const errorMessages = [...errs, ...(lbErr ? [`leaderboard: ${lbErr}`] : [])];
|
||||
const error = errorMessages.length > 0 ? errorMessages.join(" · ") : null;
|
||||
|
||||
Reference in New Issue
Block a user