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";
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>
))
+1 -12
View File
@@ -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";