diff --git a/src/app/actions/load-player-card.ts b/src/app/actions/load-player-card.ts new file mode 100644 index 0000000..0465736 --- /dev/null +++ b/src/app/actions/load-player-card.ts @@ -0,0 +1,12 @@ +"use server"; + +import { fetchPlayerCardData } from "@/lib/player-card-server"; +import { createAdminSupabase } from "@/lib/supabase/admin"; + +export async function loadPlayerCardAction(userId: number) { + const supabase = createAdminSupabase(); + if (!supabase) { + return { ok: false as const, error: "Supabase is not configured." }; + } + return fetchPlayerCardData(supabase, userId); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cc7cee5..e31c970 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { PlayerCardRoot } from "@/components/player-card-root"; import "./globals.css"; const geistSans = Geist({ @@ -27,7 +28,9 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index a9d0b4e..07001d7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,7 +18,13 @@ import { type MatchmakerLogSource, } from "@/lib/matchmaker-log-source"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; -import type { DbMatch, DbTransaction, DbUser } from "@/types/database"; +import { fetchEntryHoldPerPlayerCoinsByMatchIds } from "@/lib/match-entry-hold-from-ledger"; +import type { + AdminMatchRow, + DbMatch, + DbTransaction, + DbUser, +} from "@/types/database"; import { normalizeLedgerPage, normalizeLedgerPageSize, @@ -79,7 +85,7 @@ export default async function Home({ const supabase = createAdminSupabase(); let users: DbUser[] = []; - let matches: DbMatch[] = []; + let matches: AdminMatchRow[] = []; let configError: string | null = null; let usersError: string | null = null; let matchesError: string | null = null; @@ -137,7 +143,24 @@ export default async function Home({ if (matchesRes.error) { matchesError = matchesRes.error.message; } else { - matches = (matchesRes.data ?? []) as DbMatch[]; + const rawMatches = (matchesRes.data ?? []) as DbMatch[]; + const holdByMatch = await fetchEntryHoldPerPlayerCoinsByMatchIds( + supabase, + rawMatches + .map((m) => Number(m.id)) + .filter((id) => Number.isFinite(id)), + ); + matches = rawMatches.map((m) => { + const idNum = Number(m.id); + const hold = Number.isFinite(idNum) + ? holdByMatch.get(idNum) + : undefined; + return { + ...m, + entryHoldPerPlayerCoins: + hold !== undefined ? hold.toString() : null, + }; + }); } statsBundle = await loadDashboardStatsBundle(supabase); diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index e8d06c4..489c444 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { 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"; @@ -11,8 +12,16 @@ import { type AdminDashboardTab, } from "@/lib/dashboard-search-url"; import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source"; -import type { DbMatch, DbTransaction, DbUser } from "@/types/database"; -import type { SerializedLedgerGlobalSummary } from "@/lib/ledger-integrity"; +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. */ @@ -56,9 +65,24 @@ function formatTs(value: string | null): string { } } +function matchEntryFeeCoinString(v: DbMatch["entry_fee"]): string | null { + if (v == null) return null; + return txAmountToBigInt(v).toString(); +} + +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 Props = { users: DbUser[]; - matches: DbMatch[]; + matches: AdminMatchRow[]; usersError: string | null; matchesError: string | null; transactionsError: string | null; @@ -378,12 +402,8 @@ export function AdminDashboard({ {i + 1} - - {row.userId} + + {row.username != null && row.username !== "" ? ( {" "} @@ -392,7 +412,7 @@ export function AdminDashboard({ ) : ( (—) )} - + {row.totalPrizeCc.toLocaleString("en-US")} @@ -448,7 +468,9 @@ export function AdminDashboard({ : "text-zinc-800 dark:text-zinc-200" } > - {u.id} + + + {u.username ?? "—"} {u.cc ?? "—"} {u.rc ?? "—"} @@ -622,8 +644,9 @@ export function AdminDashboard({ matchId={m.id} statusLabel={statusLabel(m.status)} createdAtLabel={formatTs(m.created_at)} - entryFee={m.entry_fee} - prizeCc={m.prize_cc} + entryFeeCoins={matchEntryFeeCoinString(m.entry_fee)} + entryHoldPerPlayerCoins={m.entryHoldPerPlayerCoins} + prizeCcLabel={formatPrizeCcChip(m.prize_cc)} winnerId={m.winner_id} left={{ id: redId, diff --git a/src/components/admin-ledger.tsx b/src/components/admin-ledger.tsx index caeeea3..b974044 100644 --- a/src/components/admin-ledger.tsx +++ b/src/components/admin-ledger.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useMemo } from "react"; +import { ClickableUserId } from "@/components/clickable-user-id"; import { buildDashboardHref } from "@/lib/dashboard-search-url"; import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc"; import { auditMatchEconomics } from "@/lib/ledger-match-audit"; @@ -199,17 +200,7 @@ export function AdminLedger({ const un = usernameById.get(id); return ( - - {id} - + {un != null && un !== "" ? ( ({un}) ) : null} diff --git a/src/components/clickable-user-id.tsx b/src/components/clickable-user-id.tsx new file mode 100644 index 0000000..fe89ae7 --- /dev/null +++ b/src/components/clickable-user-id.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { ReactNode } from "react"; +import { usePlayerCard } from "@/components/player-card-context"; + +const defaultClass = + "cursor-pointer border-0 bg-transparent p-0 text-left font-mono text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"; + +type Props = { + id: number; + className?: string; + children?: ReactNode; +}; + +export function ClickableUserId({ id, className, children }: Props) { + const { openPlayerCard } = usePlayerCard(); + return ( + + ); +} diff --git a/src/components/match-history-battle-card.tsx b/src/components/match-history-battle-card.tsx index 89e67b0..0e57324 100644 --- a/src/components/match-history-battle-card.tsx +++ b/src/components/match-history-battle-card.tsx @@ -1,4 +1,8 @@ +"use client"; + import Link from "next/link"; +import { ClickableUserId } from "@/components/clickable-user-id"; +import { formatRcLabelFromCoinsBigInt } from "@/lib/coins-rc"; type PlayerSide = { id: number | null; @@ -12,14 +16,47 @@ type Props = { matchId: number; statusLabel: string; createdAtLabel: string; - entryFee: number | null; - prizeCc: number | null; + /** `matches.entry_fee` as coin string (bigint); RC shown in footer. */ + entryFeeCoins: string | null; + /** Ledger `entry_hold` / player as coin string; null if unknown. */ + entryHoldPerPlayerCoins: string | null; + /** Preformatted `prize_cc` for display (e.g. with grouping). */ + prizeCcLabel: string; left: PlayerSide; right: PlayerSide; /** May be string when `bigint` is JSON-serialized. */ winnerId: number | string | null; }; +function rcLabelFromCoinString(s: string | null): string { + if (s == null || String(s).trim() === "") return "—"; + try { + return formatRcLabelFromCoinsBigInt(BigInt(String(s).trim())); + } catch { + return "—"; + } +} + +function entryTotalRcLabel( + feeCoins: string | null, + holdCoins: string | null, +): string { + if ( + feeCoins == null || + holdCoins == null || + String(feeCoins).trim() === "" || + String(holdCoins).trim() === "" + ) { + return "—"; + } + try { + const sum = BigInt(String(feeCoins).trim()) + BigInt(String(holdCoins).trim()); + return formatRcLabelFromCoinsBigInt(sum); + } catch { + return "—"; + } +} + function idsMatch( a: number | string | null | undefined, b: number | string | null | undefined, @@ -110,7 +147,17 @@ function PlayerPanel({ {playerLabel(side)}

- ID: {side.id ?? "—"} + ID:{" "} + {side.id != null ? ( + + {side.id} + + ) : ( + "—" + )}

@@ -131,12 +178,17 @@ export function MatchHistoryBattleCard({ matchId, statusLabel, createdAtLabel, - entryFee, - prizeCc, + entryFeeCoins, + entryHoldPerPlayerCoins, + prizeCcLabel, left, right, winnerId, }: Props) { + const entryRc = entryTotalRcLabel(entryFeeCoins, entryHoldPerPlayerCoins); + const entryFeeRc = rcLabelFromCoinString(entryFeeCoins); + const entryHoldRc = rcLabelFromCoinString(entryHoldPerPlayerCoins); + const prizeCcDisplay = prizeCcLabel.trim() === "" ? "—" : prizeCcLabel; const hasWinner = winnerId != null && winnerId !== "" && @@ -181,15 +233,21 @@ export function MatchHistoryBattleCard({
-
+
- Entry {entryFee ?? "—"} + Time {createdAtLabel} - Prize {prizeCc ?? "—"} CC + Entry {entryRc} - {createdAtLabel} + Entry hold {entryHoldRc} + + + Prize {prizeCcDisplay} CC + + + Entry fee {entryFeeRc}
void; + closePlayerCard: () => void; + cardUserId: number | null; +}; + +const PlayerCardContext = createContext(null); + +export function PlayerCardProvider({ children }: { children: ReactNode }) { + const [cardUserId, setCardUserId] = useState(null); + + const openPlayerCard = useCallback((userId: number) => { + if (!Number.isFinite(userId) || userId < 1) return; + setCardUserId(Math.trunc(userId)); + }, []); + + const closePlayerCard = useCallback(() => { + setCardUserId(null); + }, []); + + const value = useMemo( + () => ({ + openPlayerCard, + closePlayerCard, + cardUserId, + }), + [openPlayerCard, closePlayerCard, cardUserId], + ); + + return ( + + {children} + + ); +} + +export function usePlayerCard(): Ctx { + const ctx = useContext(PlayerCardContext); + if (!ctx) { + throw new Error("usePlayerCard must be used within PlayerCardProvider"); + } + return ctx; +} diff --git a/src/components/player-card-modal.tsx b/src/components/player-card-modal.tsx new file mode 100644 index 0000000..885dde9 --- /dev/null +++ b/src/components/player-card-modal.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { + useCallback, + useEffect, + useState, + type MouseEvent, +} from "react"; +import Link from "next/link"; +import { loadPlayerCardAction } from "@/app/actions/load-player-card"; +import type { + PlayerCardData, + PlayerCardMatchHistoryRow, +} from "@/lib/player-card-server"; +import { + formatRcLabelFromCoinsBigInt, + rcToCoins, +} from "@/lib/coins-rc"; +import { usePlayerCard } from "@/components/player-card-context"; + +function formatTsUtc(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 rcFromStr(s: string): bigint { + try { + return BigInt(s); + } catch { + return BigInt(0); + } +} + +function formatCcBalance(cc: number | null): string { + if (cc == null || !Number.isFinite(cc)) return "—"; + 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"; + } + if (outcome === "Loss") { + return "font-semibold text-rose-700 dark:text-rose-400"; + } + return "font-medium text-zinc-500 dark:text-zinc-400"; +} + +function opponentLine(m: PlayerCardMatchHistoryRow): string { + if (m.opponentId == null) return "Open slot"; + const name = + m.opponentUsername != null && m.opponentUsername.trim() !== "" + ? m.opponentUsername.trim() + : "—"; + return `${name} (${m.opponentId})`; +} + +type LoadState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "ok"; data: PlayerCardData }; + +export function PlayerCardModal() { + const { cardUserId, closePlayerCard } = usePlayerCard(); + const [load, setLoad] = useState({ status: "idle" }); + + useEffect(() => { + if (cardUserId == null) { + setLoad({ status: "idle" }); + return; + } + let cancelled = false; + setLoad({ status: "loading" }); + void (async () => { + const res = await loadPlayerCardAction(cardUserId); + if (cancelled) return; + if (res.ok) { + setLoad({ status: "ok", data: res.data }); + } else { + setLoad({ status: "error", message: res.error }); + } + })(); + return () => { + cancelled = true; + }; + }, [cardUserId]); + + const onBackdrop = useCallback( + (e: MouseEvent) => { + if (e.target === e.currentTarget) closePlayerCard(); + }, + [closePlayerCard], + ); + + useEffect(() => { + if (cardUserId == null) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") closePlayerCard(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [cardUserId, closePlayerCard]); + + if (cardUserId == null) return null; + + const hold = load.status === "ok" ? rcFromStr(load.data.rcSpentHoldCoins) : BigInt(0); + const fee = load.status === "ok" ? rcFromStr(load.data.rcSpentFeeCoins) : BigInt(0); + const spentEntry = hold + fee; + + return ( +
+
e.stopPropagation()} + > +
+
+
+

+ {load.status === "ok" + ? load.data.username?.trim() + ? load.data.username + : `Player ${load.data.id}` + : `Player ${cardUserId}`} +

+

+ ID {cardUserId} +

+
+ +
+ {load.status === "ok" ? ( +
+
+ Account created + + {formatTsUtc(load.data.createdAt)} + +
+
+ Last seen + + {formatTsUtc(load.data.lastSeen)} + +
+
+ ) : null} +
+ +
+ {load.status === "loading" ? ( +

Loading…

+ ) : load.status === "error" ? ( +

+ {load.message} +

+ ) : load.status === "ok" ? ( +
+
+ + + +
+
+ + + + + + +
+
+ ) : null} +
+ + {load.status === "ok" ? ( +
+

+ Match history + {load.data.matchesPlayed > load.data.matchHistory.length ? ( + + (latest {load.data.matchHistory.length} of{" "} + {load.data.matchesPlayed}) + + ) : null} +

+
+ {load.data.matchHistory.length === 0 ? ( +

+ No matches yet. +

+ ) : ( +
    + {load.data.matchHistory.map((m) => ( +
  • +
    +
    + + + M{m.matchId} + + + {" "} + · {formatTsUtc(m.createdAt)} + + + + {m.outcomeLabel} + +
    +

    + + {m.sideLabel} + + {" · "} + vs {opponentLine(m)} + {" · "} + Entry{" "} + {formatRcLabelFromCoinsBigInt( + rcFromStr(m.entryTotalCoins), + )} + {" · "} + Entry fee{" "} + {formatRcLabelFromCoinsBigInt( + rcFromStr(m.entryFeeCoins), + )} +

    +
    +
  • + ))} +
+ )} +
+
+ ) : null} +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} diff --git a/src/components/player-card-root.tsx b/src/components/player-card-root.tsx new file mode 100644 index 0000000..026ac07 --- /dev/null +++ b/src/components/player-card-root.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { PlayerCardProvider } from "@/components/player-card-context"; +import { PlayerCardModal } from "@/components/player-card-modal"; + +export function PlayerCardRoot({ children }: { children: React.ReactNode }) { + return ( + + {children} + + + ); +} diff --git a/src/lib/match-entry-hold-from-ledger.ts b/src/lib/match-entry-hold-from-ledger.ts new file mode 100644 index 0000000..b85139d --- /dev/null +++ b/src/lib/match-entry-hold-from-ledger.ts @@ -0,0 +1,56 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DbTransaction } from "@/types/database"; +import { txAmountToBigInt } from "@/lib/ledger-integrity"; + +function normRemark(r: string | null | undefined): string { + return (r ?? "").toLowerCase().trim(); +} + +/** + * For each match_id, average `entry_hold` debit amount (same per player when + * both rows exist; sum/count handles odd counts). + */ +export async function fetchEntryHoldPerPlayerCoinsByMatchIds( + supabase: SupabaseClient, + matchIds: number[], +): Promise> { + const out = new Map(); + if (matchIds.length === 0) return out; + + const { data, error } = await supabase + .from("transactions") + .select("match_id, amount, remarks") + .in("match_id", matchIds); + + if (error) { + return out; + } + + const sums = new Map(); + const counts = new Map(); + + for (const row of (data ?? []) as Pick< + DbTransaction, + "match_id" | "amount" | "remarks" + >[]) { + if (normRemark(row.remarks) !== "entry_hold") { + continue; + } + const midRaw = row.match_id; + if (midRaw == null) continue; + const mid = + typeof midRaw === "number" ? midRaw : Math.trunc(Number(midRaw)); + if (!Number.isFinite(mid)) continue; + const a = txAmountToBigInt(row.amount); + if (a === BigInt(0)) continue; + sums.set(mid, (sums.get(mid) ?? BigInt(0)) + a); + counts.set(mid, (counts.get(mid) ?? 0) + 1); + } + + for (const [mid, sum] of sums) { + const c = counts.get(mid) ?? 0; + if (c > 0) out.set(mid, sum / BigInt(c)); + } + + return out; +} diff --git a/src/lib/player-card-server.ts b/src/lib/player-card-server.ts new file mode 100644 index 0000000..d57c629 --- /dev/null +++ b/src/lib/player-card-server.ts @@ -0,0 +1,287 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DbMatch, DbTransaction } from "@/types/database"; +import { txAmountToBigInt } from "@/lib/ledger-integrity"; + +const TX_PAGE = 5000; +/** Recent matches listed on the player card (newest first). */ +const MATCH_HISTORY_LIMIT = 200; + +export type PlayerCardMatchHistoryRow = { + matchId: number; + createdAt: string; + sideLabel: "Red" | "Blue"; + opponentId: number | null; + opponentUsername: string | null; + outcomeLabel: "Win" | "Loss" | "Pending"; + /** Coins debited: entry_hold + entry_fee for this user on this match. */ + entryTotalCoins: string; + /** Coins debited: entry_fee only for this user on this match. */ + entryFeeCoins: string; +}; + +export type PlayerCardData = { + id: number; + username: string | null; + createdAt: string | null; + lastSeen: string | null; + rcBalance: number | null; + ccBalance: number | null; + matchesPlayed: number; + matchesWon: number; + winRatePercent: number | null; + rcPurchasedCoins: string; + rcRewardCoins: string; + rcSpentHoldCoins: string; + rcSpentFeeCoins: string; + matchHistory: PlayerCardMatchHistoryRow[]; +}; + +function cellUserId(v: number | string | null | undefined): number | null { + if (v == null) return null; + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) ? Math.trunc(n) : null; +} + +function sameUser( + cell: number | string | null | undefined, + userId: number, +): boolean { + return cellUserId(cell) === userId; +} + +function normRemark(r: string | null | undefined): string { + return (r ?? "").toLowerCase().trim(); +} + +function winnerRecorded(winnerId: DbMatch["winner_id"]): boolean { + const w = cellUserId(winnerId); + return w != null && w > 0; +} + +function outcomeForParticipant( + userId: number, + winnerId: DbMatch["winner_id"], + opponentId: number | null, +): "Win" | "Loss" | "Pending" { + if (!winnerRecorded(winnerId)) return "Pending"; + const wid = cellUserId(winnerId); + if (wid === userId) return "Win"; + if (opponentId != null && wid === opponentId) return "Loss"; + return "Pending"; +} + +export async function fetchPlayerCardData( + supabase: SupabaseClient, + userId: number, +): Promise< + | { ok: true; data: PlayerCardData } + | { ok: false; error: string } +> { + if (!Number.isInteger(userId) || userId < 1) { + return { ok: false, error: "Invalid player id." }; + } + + const { data: userRow, error: userErr } = await supabase + .from("users") + .select("id, username, created_at, last_logged_at, rc, cc") + .eq("id", userId) + .maybeSingle(); + + if (userErr) { + return { ok: false, error: userErr.message }; + } + if (!userRow) { + return { ok: false, error: "Player not found." }; + } + + const [ + { count: playedCount, error: playedErr }, + { count: wonCount, error: wonErr }, + matchesListRes, + ] = await Promise.all([ + supabase + .from("matches") + .select("id", { count: "exact", head: true }) + .or(`user_red.eq.${userId},user_blue.eq.${userId}`), + supabase + .from("matches") + .select("id", { count: "exact", head: true }) + .eq("winner_id", userId), + supabase + .from("matches") + .select("id, created_at, user_red, user_blue, winner_id") + .or(`user_red.eq.${userId},user_blue.eq.${userId}`) + .order("id", { ascending: false }) + .limit(MATCH_HISTORY_LIMIT), + ]); + + if (playedErr) { + return { ok: false, error: playedErr.message }; + } + if (wonErr) { + return { ok: false, error: wonErr.message }; + } + if (matchesListRes.error) { + return { ok: false, error: matchesListRes.error.message }; + } + + const rawMatches = (matchesListRes.data ?? []) as Pick< + DbMatch, + "id" | "created_at" | "user_red" | "user_blue" | "winner_id" + >[]; + + const matchIds = rawMatches.map((m) => m.id as number); + const entryByMatch = new Map(); + for (const mid of matchIds) { + entryByMatch.set(mid, { hold: BigInt(0), fee: BigInt(0) }); + } + + if (matchIds.length > 0) { + const { data: entryTxRows, error: entryTxErr } = await supabase + .from("transactions") + .select("match_id, amount, remarks") + .eq("from", userId) + .in("match_id", matchIds); + + if (entryTxErr) { + return { ok: false, error: entryTxErr.message }; + } + + for (const row of entryTxRows ?? []) { + const midRaw = row.match_id; + if (midRaw == null) continue; + const midNum = + typeof midRaw === "number" ? midRaw : Number(midRaw); + if (!Number.isFinite(midNum)) continue; + const mid = Math.trunc(midNum); + const a = txAmountToBigInt(row.amount); + if (a === BigInt(0)) continue; + const rmk = normRemark(row.remarks); + const cur = entryByMatch.get(mid) ?? { + hold: BigInt(0), + fee: BigInt(0), + }; + if (rmk === "entry_hold") { + cur.hold += a; + } else if (rmk === "entry_fee") { + cur.fee += a; + } + entryByMatch.set(mid, cur); + } + } + const opponentIds = new Set(); + for (const m of rawMatches) { + const red = cellUserId(m.user_red); + const blue = cellUserId(m.user_blue); + if (red === userId && blue != null) opponentIds.add(blue); + else if (blue === userId && red != null) opponentIds.add(red); + } + + const nameById = new Map(); + if (opponentIds.size > 0) { + const { data: nameRows, error: nameErr } = await supabase + .from("users") + .select("id, username") + .in("id", [...opponentIds]); + if (nameErr) { + return { ok: false, error: nameErr.message }; + } + for (const row of nameRows ?? []) { + nameById.set(row.id as number, (row.username as string | null) ?? null); + } + } + + const matchHistory: PlayerCardMatchHistoryRow[] = rawMatches.map((m) => { + const red = cellUserId(m.user_red); + const blue = cellUserId(m.user_blue); + const onRed = red === userId; + const opponentId = onRed ? blue : red; + const opponentUsername = + opponentId != null ? nameById.get(opponentId) ?? null : null; + const mid = m.id as number; + const e = entryByMatch.get(mid) ?? { hold: BigInt(0), fee: BigInt(0) }; + const entryTotal = e.hold + e.fee; + return { + matchId: mid, + createdAt: m.created_at as string, + sideLabel: onRed ? "Red" : "Blue", + opponentId, + opponentUsername, + outcomeLabel: outcomeForParticipant(userId, m.winner_id, opponentId), + entryTotalCoins: entryTotal.toString(), + entryFeeCoins: e.fee.toString(), + }; + }); + + const matchesPlayed = playedCount ?? 0; + const matchesWon = wonCount ?? 0; + const winRatePercent = + matchesPlayed > 0 + ? Math.round((matchesWon / matchesPlayed) * 1000) / 10 + : null; + + let purchased = BigInt(0); + let reward = BigInt(0); + let holdSpend = BigInt(0); + let feeSpend = BigInt(0); + let offset = 0; + + for (;;) { + const { data: batch, error: txErr } = await supabase + .from("transactions") + .select("amount, remarks, from, to") + .or(`from.eq.${userId},to.eq.${userId}`) + .order("id", { ascending: true }) + .range(offset, offset + TX_PAGE - 1); + + if (txErr) { + return { ok: false, error: txErr.message }; + } + const rows = (batch ?? []) as Pick< + DbTransaction, + "amount" | "remarks" | "from" | "to" + >[]; + if (rows.length === 0) break; + + for (const row of rows) { + const a = txAmountToBigInt(row.amount); + if (a === BigInt(0)) continue; + const rmk = normRemark(row.remarks); + if (sameUser(row.to, userId) && rmk === "purchase") { + purchased += a; + } + if (sameUser(row.to, userId) && rmk === "reward") { + reward += a; + } + if (sameUser(row.from, userId) && rmk === "entry_hold") { + holdSpend += a; + } + if (sameUser(row.from, userId) && rmk === "entry_fee") { + feeSpend += a; + } + } + + offset += rows.length; + if (rows.length < TX_PAGE) break; + } + + return { + ok: true, + data: { + id: userRow.id as number, + username: (userRow.username as string | null) ?? null, + createdAt: (userRow.created_at as string) ?? null, + lastSeen: (userRow.last_logged_at as string | null) ?? null, + rcBalance: (userRow.rc as number | null) ?? null, + ccBalance: (userRow.cc as number | null) ?? null, + matchesPlayed, + matchesWon, + winRatePercent, + rcPurchasedCoins: purchased.toString(), + rcRewardCoins: reward.toString(), + rcSpentHoldCoins: holdSpend.toString(), + rcSpentFeeCoins: feeSpend.toString(), + matchHistory, + }, + }; +} diff --git a/src/types/database.ts b/src/types/database.ts index f74c606..b46d159 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -25,6 +25,12 @@ export type DbMatch = { winner_id: number | string | null; }; +/** `matches` row plus ledger-derived fields for the admin matches tab. */ +export type AdminMatchRow = DbMatch & { + /** Per-player `entry_hold` debit in coins (stringified bigint); null if unknown. */ + entryHoldPerPlayerCoins: string | null; +}; + /** Mirrors `public.settings` (key/value config rows). */ export type DbSetting = { key: string;