leaderboard updated
This commit is contained in:
@@ -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>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user