Files
kickkingsadmin/src/components/admin-dashboard.tsx
T
2026-05-12 16:32:05 +05:30

847 lines
30 KiB
TypeScript

"use client";
import Link from "next/link";
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, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import {
buildDashboardHref,
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import type {
AdminMatchRow,
DbMatch,
DbTransaction,
DbUser,
} from "@/types/database";
import {
txAmountToBigInt,
type SerializedLedgerGlobalSummary,
} from "@/lib/ledger-integrity";
import type { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view";
/** Bigint columns often arrive as strings from PostgREST / JSON. */
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
if (winnerId == null) return false;
if (typeof winnerId === "number") {
return Number.isInteger(winnerId) && winnerId > 0;
}
if (typeof winnerId === "string") {
const t = winnerId.trim();
if (t === "" || !/^-?\d+$/.test(t)) return false;
const n = Number(t);
return Number.isFinite(n) && n > 0;
}
return false;
}
function participantInMatch(m: DbMatch, participantId: number): boolean {
const red = m.user_red;
const blue = m.user_blue;
if (red != null && Number(red) === participantId) return true;
if (blue != null && Number(blue) === participantId) return true;
return false;
}
function coalesceUserId(v: number | string | null): number | null {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? Math.trunc(n) : null;
}
/** UTC ISO slice — identical on server and client (avoids hydration mismatch from `toLocaleString`). */
function formatTs(value: string | null): string {
if (!value) return "—";
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toISOString().replace("T", " ").slice(0, 19) + " UTC";
} catch {
return value;
}
}
function formatPrizeCcChip(v: DbMatch["prize_cc"]): string {
if (v == null) return "—";
const b = txAmountToBigInt(v);
try {
return b.toLocaleString("en-US");
} catch {
return String(v);
}
}
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[];
usersError: string | null;
matchesError: string | null;
transactionsError: string | null;
/** Ledger tab: full-database totals + user nets (server scan). */
ledgerGlobalSummary: SerializedLedgerGlobalSummary | null;
/** Ledger tab: rows in the selected UTC date range (full set for audits). */
ledgerTransactionsFiltered: DbTransaction[];
/** Ledger tab: current page of the ledger table (sorted + paginated). */
ledgerTableRows: DbTransaction[];
ledgerTotalRowsInRange: number;
ledgerPage: number;
ledgerPageSize: number;
ledgerTotalPages: number;
ledgerSort: LedgerSortKey;
ledgerOrder: LedgerSortOrder;
ledgerFrom: string;
ledgerTo: string;
statsBundle: DashboardStatsBundle | null;
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
tab: AdminDashboardTab;
highlightId: string | null;
participantRaw: string | null;
matchmakerSource: MatchmakerLogSource;
matchmakerContent: string;
matchmakerError: string | null;
};
function StatCard({
title,
value,
}: {
title: string;
value: number | null;
}) {
const display =
value == null ? "—" : value.toLocaleString("en-US");
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-col items-center text-center">
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
{title}
</p>
<p className="mt-1.5 text-3xl font-semibold tabular-nums tracking-tight text-zinc-900 dark:text-zinc-50">
{display}
</p>
</div>
</div>
);
}
export function AdminDashboard({
users,
matches,
usersError,
matchesError,
transactionsError,
ledgerGlobalSummary,
ledgerTransactionsFiltered,
ledgerTableRows,
ledgerTotalRowsInRange,
ledgerPage,
ledgerPageSize,
ledgerTotalPages,
ledgerSort,
ledgerOrder,
ledgerFrom,
ledgerTo,
statsBundle,
tab,
highlightId,
participantRaw,
matchmakerSource,
matchmakerContent,
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>();
for (const u of users) {
m.set(u.id, u);
}
return m;
}, [users]);
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
for (const u of users) {
m.set(u.id, u.username);
}
return m;
}, [users]);
const participantId = useMemo(() => {
if (!participantRaw) return null;
const n = Number(participantRaw);
return Number.isFinite(n) ? n : null;
}, [participantRaw]);
const filteredMatches = useMemo(() => {
if (participantId == null) return matches;
return matches.filter((m) => participantInMatch(m, participantId));
}, [matches, participantId]);
const visibleMatches = useMemo(() => {
if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]);
useEffect(() => {
if (tab !== "players" || !highlightId) return;
const el = document.getElementById(`player-row-${highlightId}`);
el?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [tab, highlightId]);
const tabClass = (active: boolean) =>
[
"inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium transition",
active
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "bg-zinc-100 text-zinc-700 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700",
].join(" ");
function editHref(u: DbUser): string {
return buildDashboardHref({
tab,
highlightId,
participantRaw,
editId: u.id,
});
}
const totalUsersLabel =
statsBundle?.stats?.totalUsers ?? users.length;
const totalMatchesLabel =
statsBundle?.stats?.totalMatches ?? matches.length;
const statusLabel = (status: number | null) => {
if (status === 2) return "Final";
if (status === 1) return "Live";
if (status === 0) return "Waiting";
return status == null ? "Unknown" : `Status ${status}`;
};
return (
<main className="flex-1 space-y-6 px-6 py-8">
<div className="flex flex-wrap gap-2 border-b border-zinc-200 pb-4 dark:border-zinc-800">
<Link
href={buildDashboardHref({
tab: "dashboard",
highlightId,
participantRaw,
})}
className={tabClass(tab === "dashboard")}
scroll={false}
>
Overview
</Link>
<Link
href={buildDashboardHref({
tab: "players",
highlightId,
participantRaw,
})}
className={tabClass(tab === "players")}
scroll={false}
>
Players (
{totalUsersLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${users.length.toLocaleString("en-US")}`
: null}
)
</Link>
<Link
href={buildDashboardHref({
tab: "matches",
highlightId,
participantRaw,
})}
className={tabClass(tab === "matches")}
scroll={false}
>
Matches (
{totalMatchesLabel.toLocaleString("en-US")}
{!statsBundle?.stats
? ` · table ${matches.length.toLocaleString("en-US")}`
: null}
)
</Link>
<Link
href={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
})}
className={tabClass(tab === "matchmaker")}
scroll={false}
>
Matchmaker
</Link>
<Link
href={buildDashboardHref({
tab: "ledger",
highlightId,
participantRaw,
})}
className={tabClass(tab === "ledger")}
scroll={false}
>
Ledger
</Link>
<Link
href="/settings"
className={tabClass(false)}
scroll={false}
>
Settings
</Link>
</div>
<div className="mx-auto w-full max-w-[1400px]">
{tab === "dashboard" ? (
<section className="space-y-8">
{statsBundle?.error ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
{statsBundle.error}
</div>
) : null}
<div className="grid gap-8 xl:grid-cols-2">
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Active players
</h2>
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
<StatCard
title="Active players today"
value={statsBundle?.stats?.activePlayersToday ?? null}
/>
<StatCard
title="Active players (last 7 days)"
value={statsBundle?.stats?.activePlayersLastWeek ?? null}
/>
<StatCard
title="Active players (last 30 days)"
value={statsBundle?.stats?.activePlayersLastMonth ?? null}
/>
</div>
</div>
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Matches
</h2>
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
<StatCard
title="Matches today"
value={statsBundle?.stats?.matchesToday ?? null}
/>
<StatCard
title="Matches (last 7 days)"
value={statsBundle?.stats?.matchesLastWeek ?? null}
/>
<StatCard
title="Matches (last 30 days)"
value={statsBundle?.stats?.matchesLastMonth ?? null}
/>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<StatCard
title="Total users"
value={statsBundle?.stats?.totalUsers ?? null}
/>
<StatCard
title="Total matches"
value={statsBundle?.stats?.totalMatches ?? null}
/>
</div>
<div>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
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>
<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={5}
className="px-4 py-8 text-center text-zinc-500"
>
No wins recorded yet (no rows with a winner).
</td>
</tr>
) : (
sortedLeaderboard.map((row, i) => (
<tr
key={row.userId}
className="text-zinc-800 dark:text-zinc-200"
>
<td className="px-4 py-2 font-mono text-xs text-zinc-500">
{i + 1}
</td>
<td className="px-4 py-2">
<span className="font-mono text-xs">
<ClickableUserId id={row.userId} />
{row.username != null && row.username !== "" ? (
<span className="text-zinc-700 dark:text-zinc-300">
{" "}
({row.username})
</span>
) : (
<span className="text-zinc-500"> ()</span>
)}
</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.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>
))
)}
</tbody>
</table>
</div>
</div>
</section>
) : tab === "players" ? (
<section>
{usersError ? (
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
) : (
<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">ID</th>
<th className="px-4 py-3 font-medium">Username</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">Created</th>
<th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{users.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-4 py-8 text-center text-zinc-500"
>
No players yet.
</td>
</tr>
) : (
users.map((u) => {
const isHi =
highlightId != null &&
highlightId === String(u.id);
return (
<tr
key={u.id}
id={`player-row-${u.id}`}
className={
isHi
? "bg-amber-100/90 ring-2 ring-inset ring-amber-400/80 dark:bg-amber-950/50 dark:ring-amber-500/60"
: "text-zinc-800 dark:text-zinc-200"
}
>
<td className="px-4 py-2 font-mono text-xs">
<ClickableUserId id={u.id} />
</td>
<td className="px-4 py-2">{u.username ?? "—"}</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 whitespace-nowrap">
{formatTs(u.created_at)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.last_logged_at)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2">
<Link
href={editHref(u)}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Edit
</Link>
<Link
href={`/?tab=matches&participant=${u.id}`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
scroll={false}
>
Show matches
</Link>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
</section>
) : tab === "matchmaker" ? (
<section className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-zinc-600 dark:text-zinc-400">
<p>
<span className="font-mono text-zinc-700 dark:text-zinc-300">
history.log
</span>{" "}
is the processed summary;{" "}
<span className="font-mono text-zinc-700 dark:text-zinc-300">
matchmaker.log
</span>{" "}
is raw output. Toggle below or{" "}
<Link
href={`/matchmaker-logs${matchmakerSource === "raw" ? "?mklog=raw" : ""}`}
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
target="_blank"
rel="noopener noreferrer"
>
open fullscreen
</Link>
.
</p>
</div>
<MatchmakerLogTerminal
layout="embedded"
source={matchmakerSource}
processedHref={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
})}
rawHref={buildDashboardHref({
tab: "matchmaker",
highlightId,
participantRaw,
matchmakerSource: "raw",
})}
content={matchmakerContent}
errorMessage={matchmakerError}
/>
</section>
) : tab === "ledger" ? (
<AdminLedger
ledgerGlobalSummary={ledgerGlobalSummary}
transactionsScoped={ledgerTransactionsFiltered}
ledgerTableRows={ledgerTableRows}
ledgerTotalRowsInRange={ledgerTotalRowsInRange}
ledgerPage={ledgerPage}
ledgerPageSize={ledgerPageSize}
ledgerTotalPages={ledgerTotalPages}
ledgerSort={ledgerSort}
ledgerOrder={ledgerOrder}
users={users}
error={transactionsError}
highlightId={highlightId}
participantRaw={participantRaw}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
/>
) : (
<section className="space-y-3">
{participantId != null ? (
<div
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-100"
role="status"
>
<span>
Showing matches where{" "}
<span className="font-mono font-medium">
{participantId}
</span>
{usernameById.get(participantId) != null &&
usernameById.get(participantId) !== "" ? (
<span>
{" "}
({usernameById.get(participantId)})
</span>
) : null}{" "}
is the Red or Blue user (
{filteredMatches.length} of {matches.length})
</span>
<Link
href="/?tab=matches"
className="shrink-0 rounded-md border border-sky-300 bg-white px-3 py-1.5 text-xs font-medium text-sky-900 shadow-sm hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-900 dark:text-sky-50 dark:hover:bg-sky-800"
scroll={false}
>
Clear filter
</Link>
</div>
) : null}
{matchesError ? (
<p className="text-sm text-red-600 dark:text-red-400">
{matchesError}
</p>
) : (
<div className="grid gap-3">
<div className="relative z-10 mb-1 flex items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<label
htmlFor="admin-hide-matches-no-winner"
className="inline-flex cursor-pointer items-center gap-2 text-zinc-700 dark:text-zinc-200"
>
<input
id="admin-hide-matches-no-winner"
type="checkbox"
checked={hideNoWinner}
onChange={(e) => setHideNoWinner(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"
/>
Hide matches with no winner
</label>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {visibleMatches.length} of {filteredMatches.length}
</span>
</div>
{visibleMatches.length === 0 ? (
<div className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
{filteredMatches.length === 0
? "No matches yet."
: hideNoWinner
? "No matches left after hiding no-winner matches."
: "No matches for this filter."}
</div>
) : (
visibleMatches.map((m) => {
const redId = coalesceUserId(m.user_red);
const blueId = coalesceUserId(m.user_blue);
const leftUser =
redId != null ? userById.get(redId) : null;
const rightUser =
blueId != null ? userById.get(blueId) : null;
return (
<MatchHistoryBattleCard
key={m.id}
matchId={m.id}
statusLabel={statusLabel(m.status)}
createdAtLabel={formatTs(m.created_at)}
entryFeeCoins={m.entryFeePerPlayerCoins}
entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins}
prizeCcLabel={formatPrizeCcChip(m.prize_cc)}
winnerId={m.winner_id}
left={{
id: redId,
username: leftUser?.username ?? null,
cc: leftUser?.cc ?? null,
rc: leftUser?.rc ?? null,
color: "red",
}}
right={{
id: blueId,
username: rightUser?.username ?? null,
cc: rightUser?.cc ?? null,
rc: rightUser?.rc ?? null,
color: "blue",
}}
/>
);
})
)}
</div>
)}
</section>
)}
</div>
</main>
);
}