show escrow accounts

This commit is contained in:
2026-09-06 14:04:16 +00:00
parent aca0c17e4d
commit c5084170b8
11 changed files with 325 additions and 34 deletions
+68 -20
View File
@@ -86,7 +86,7 @@ function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
}
}
type LeaderboardSortKey = "rank" | "player" | "rc" | "winRate" | "wins";
type LeaderboardSortKey = "rank" | "player" | "rc" | "mmr" | "winRate" | "wins";
function playerSortKey(row: LeaderboardRow): string {
const t = row.username?.trim();
@@ -94,6 +94,10 @@ function playerSortKey(row: LeaderboardRow): string {
return "\uffff";
}
function isEscrowUsername(username: string | null): boolean {
return (username ?? "").toLowerCase().startsWith("match_escrow_");
}
function leaderboardDefaultSortDir(key: LeaderboardSortKey): "asc" | "desc" {
if (key === "player" || key === "rank") return "asc";
return "desc";
@@ -130,6 +134,15 @@ function sortLeaderboardRows(
else cmp = a.rcBalance! - b.rcBalance!;
break;
}
case "mmr": {
const aOk = a.mmr != null && Number.isFinite(a.mmr);
const bOk = b.mmr != null && Number.isFinite(b.mmr);
if (!aOk && !bOk) cmp = 0;
else if (!aOk) cmp = 1;
else if (!bOk) cmp = -1;
else cmp = a.mmr! - b.mmr!;
break;
}
case "winRate": {
const aOk =
a.winRatePercent != null && Number.isFinite(a.winRatePercent);
@@ -252,6 +265,8 @@ type Props = {
canWritePlayers: boolean;
canWriteLedger: boolean;
isAdmin: boolean;
/** Match IDs that have a replay JSON file on disk. */
matchIdsWithReplay: number[];
};
function StatCard({
@@ -318,10 +333,16 @@ export function AdminDashboard({
canWritePlayers,
canWriteLedger,
isAdmin,
matchIdsWithReplay,
}: Props) {
const replaySet = useMemo(
() => new Set(matchIdsWithReplay),
[matchIdsWithReplay],
);
const [hideNoWinner, setHideNoWinner] = useState(true);
const [playersSearch, setPlayersSearch] = useState("");
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
const [showEscrowAccounts, setShowEscrowAccounts] = useState(false);
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("mmr");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
/** Once we know the browser offset, reload with mtz so day bounds are local. */
@@ -399,16 +420,21 @@ export function AdminDashboard({
if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]);
const playersPool = useMemo(() => {
if (showEscrowAccounts) return users;
return users.filter((u) => !isEscrowUsername(u.username));
}, [users, showEscrowAccounts]);
const filteredUsers = useMemo(() => {
const q = playersSearch.trim().toLowerCase();
if (!q) return users;
return users.filter((u) => {
if (!q) return playersPool;
return playersPool.filter((u) => {
const haystack = [
String(u.id),
u.username ?? "",
u.email ?? "",
u.cc == null ? "" : String(u.cc),
u.rc == null ? "" : String(u.rc),
u.mmr == null ? "" : String(u.mmr),
formatTs(u.created_at),
formatTs(u.last_logged_at),
u.ip_address ?? "",
@@ -417,7 +443,7 @@ export function AdminDashboard({
.toLowerCase();
return haystack.includes(q);
});
}, [users, playersSearch]);
}, [playersPool, playersSearch]);
useEffect(() => {
if (tab !== "players" || !highlightId) return;
@@ -564,15 +590,6 @@ export function AdminDashboard({
System logs
</Link>
) : null}
{pageAccess.matches || pageAccess.analysis ? (
<Link
href="/replays"
className={tabClass(false)}
scroll={false}
>
Replays
</Link>
) : null}
{isAdmin ? (
<Link
href="/settings"
@@ -679,6 +696,14 @@ export function AdminDashboard({
activeDir={lbSortDir}
onActivate={onLbSort}
/>
<LeaderboardSortTh
label="MMR"
sortKey="mmr"
activeKey={lbSortKey}
activeDir={lbSortDir}
onActivate={onLbSort}
align="right"
/>
<LeaderboardSortTh
label="Win rate"
sortKey="winRate"
@@ -701,7 +726,7 @@ export function AdminDashboard({
{(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr>
<td
colSpan={6}
colSpan={7}
className="px-4 py-8 text-center text-zinc-500"
>
No wins recorded yet (no rows with a winner).
@@ -735,6 +760,11 @@ export function AdminDashboard({
<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.mmr == null
? "—"
: row.mmr.toLocaleString("en-US")}
</td>
<td className="px-4 py-2 text-right font-mono tabular-nums">
{row.winRatePercent == null
? "—"
@@ -767,12 +797,25 @@ export function AdminDashboard({
type="search"
value={playersSearch}
onChange={(e) => setPlayersSearch(e.target.value)}
placeholder="Search by any field (id, username, email, CC, RC, timestamps)"
placeholder="Search by any field (id, username, email, CC, RC, MMR, timestamps)"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-500 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-400"
/>
</label>
<label
htmlFor="admin-show-escrow-accounts"
className="inline-flex cursor-pointer items-center gap-2 text-sm whitespace-nowrap text-zinc-700 dark:text-zinc-200"
>
<input
id="admin-show-escrow-accounts"
type="checkbox"
checked={showEscrowAccounts}
onChange={(e) => setShowEscrowAccounts(e.target.checked)}
className="h-4 w-4 rounded border-zinc-300 text-sky-600 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-800"
/>
Show escrow accounts
</label>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filteredUsers.length} of {users.length}
Showing {filteredUsers.length} of {playersPool.length}
</span>
</div>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
@@ -784,6 +827,7 @@ export function AdminDashboard({
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">CC</th>
<th className="px-4 py-3 font-medium">RC</th>
<th className="px-4 py-3 font-medium">MMR</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">IP</th>
@@ -794,11 +838,13 @@ export function AdminDashboard({
{filteredUsers.length === 0 ? (
<tr>
<td
colSpan={9}
colSpan={10}
className="px-4 py-8 text-center text-zinc-500"
>
{users.length === 0
? "No players yet."
{playersPool.length === 0
? users.length === 0
? "No players yet."
: "No players left after hiding escrow accounts."
: "No players match this search."}
</td>
</tr>
@@ -826,6 +872,7 @@ export function AdminDashboard({
</td>
<td className="px-4 py-2">{u.cc ?? "—"}</td>
<td className="px-4 py-2">{u.rc ?? "—"}</td>
<td className="px-4 py-2">{u.mmr ?? "—"}</td>
<td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.created_at)}
</td>
@@ -1119,6 +1166,7 @@ export function AdminDashboard({
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
winnerId={m.winner_id}
hasReplay={replaySet.has(Number(m.id))}
left={{
id: redId,
username: leftUser?.username ?? null,