From e5414a99204efae63f1c5a3e76a9a456e5ffefa8 Mon Sep 17 00:00:00 2001 From: Sewmina Date: Tue, 12 May 2026 13:59:12 +0530 Subject: [PATCH] leaderboard updated --- src/components/admin-dashboard.tsx | 197 +++++++++++++++++++++++++-- src/components/player-card-modal.tsx | 13 +- src/lib/coins-rc.ts | 11 ++ src/lib/dashboard-stats.ts | 94 +++++++++---- 4 files changed, 263 insertions(+), 52 deletions(-) diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index 489c444..bd0d0d0 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -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 ( + + + + ); +} + type Props = { users: DbUser[]; matches: AdminMatchRow[]; @@ -159,6 +271,26 @@ export function AdminDashboard({ matchmakerError, }: Props) { const [hideNoWinner, setHideNoWinner] = useState(true); + const [lbSortKey, setLbSortKey] = useState("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(); @@ -369,31 +501,63 @@ export function AdminDashboard({

- Top players by match winnings + Top players by match wins

- - - + + + + + {(statsBundle?.leaderboard ?? []).length === 0 ? ( ) : ( - (statsBundle?.leaderboard ?? []).map((row, i) => ( + sortedLeaderboard.map((row, i) => ( + + )) diff --git a/src/components/player-card-modal.tsx b/src/components/player-card-modal.tsx index 885dde9..52a0d7d 100644 --- a/src/components/player-card-modal.tsx +++ b/src/components/player-card-modal.tsx @@ -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"; diff --git a/src/lib/coins-rc.ts b/src/lib/coins-rc.ts index 3f9b2c9..f91bb58 100644 --- a/src/lib/coins-rc.ts +++ b/src/lib/coins-rc.ts @@ -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`; +} diff --git a/src/lib/dashboard-stats.ts b/src/lib/dashboard-stats.ts index aa81e20..fe292ca 100644 --- a/src/lib/dashboard-stats.ts +++ b/src/lib/dashboard-stats.ts @@ -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(); + const winCounts = new Map(); 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(); - for (const u of userRows ?? []) { - nameById.set(u.id as number, (u.username as string | null) ?? null); + const rcById = new Map(); + 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(); + 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;
#Player - Total prize CC -
No wins recorded yet (no rows with a winner).
+ {formatRcBalanceWithCoins(row.rcBalance)} + - {row.totalPrizeCc.toLocaleString("en-US")} + {row.winRatePercent == null + ? "—" + : `${row.winRatePercent.toLocaleString("en-US", { + maximumFractionDigits: 1, + minimumFractionDigits: 0, + })}%`} + + {row.matchesWon.toLocaleString("en-US")}