SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
7.7 KB · 159 lines tsx
Raw Blame History
1"use client";23import * as React from "react";4import { CheckCircle2, XCircle } from "lucide-react";5import { TRANSACTION_TYPES } from "@spinza/shared";6import { useAdminQuery } from "@/components/admin/use-query";7import type { EconomyResponse } from "@/components/admin/types";8import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TileSkeleton, TableSkeleton, StatGrid, StatTile, SegmentedControl, Pill, type Column } from "@/components/admin/primitives";9import { Bars, ChartFrame, CHART, Histogram, type Series } from "@/components/admin/charts";10import { compact, int, isoDay, num, pct, sc, shortDate, signedSC } from "@/components/admin/format";11import { cn } from "@/lib/utils";1213const DAYS = [7, 30, 90] as const;1415/** 9 ledger types folded into 5 legible series (the table below keeps every type). */16const GROUPS: { key: string; label: string; color: string; types: string[] }[] = [17  { key: "bet", label: "Bets", color: CHART.grey2, types: ["BET"] },18  { key: "win", label: "Wins", color: CHART.accent, types: ["WIN"] },19  { key: "rewards", label: "Rewards", color: CHART.info, types: ["DAILY_REWARD", "ACHIEVEMENT", "MISSION", "LEVEL_UP"] },20  { key: "grants", label: "Grants", color: CHART.success, types: ["INITIAL_GRANT", "RESCUE_CREDITS"] },21  { key: "admin", label: "Admin", color: CHART.danger, types: ["ADMIN_ADJUSTMENT"] },22];23const SERIES: Series[] = GROUPS.map((g) => ({ key: g.key, label: g.label, color: g.color }));24const groupOf = (type: string) => GROUPS.find((g) => g.types.includes(type))?.key ?? "grants";2526const TYPE_TONE: Record<string, "muted" | "accent" | "info" | "success" | "warn" | "neutral"> = { BET: "muted", WIN: "accent", DAILY_REWARD: "info", ACHIEVEMENT: "info", MISSION: "info", LEVEL_UP: "info", INITIAL_GRANT: "success", RESCUE_CREDITS: "success", ADMIN_ADJUSTMENT: "warn" };2728interface TypeRow {29  type: string;30  n: number;31  total: number;32}3334const typeCols: Column<TypeRow>[] = [35  { key: "type", header: "Type", render: (r) => <Pill tone={TYPE_TONE[r.type] ?? "neutral"}>{r.type}</Pill> },36  { key: "group", header: "Group", render: (r) => <span className="text-fg-3">{GROUPS.find((g) => g.key === groupOf(r.type))?.label}</span> },37  { key: "n", header: "Transactions", align: "right", render: (r) => int(r.n) },38  { key: "total", header: "Signed total", align: "right", render: (r) => <span className={cn("font-medium", r.total > 0 ? "text-success" : r.total < 0 ? "text-fg-2" : "text-fg-3")}>{signedSC(r.total)}</span> },39  { key: "avg", header: "Avg / txn", align: "right", render: (r) => (r.n ? signedSC(Math.round(r.total / r.n)) : "—") },40];4142export default function AdminEconomyPage() {43  const [days, setDays] = React.useState<(typeof DAYS)[number]>(30);44  const q = useAdminQuery<EconomyResponse>(`/api/admin/economy?days=${days}`);45  const d = q.data;4647  const byType = React.useMemo<TypeRow[]>(() => {48    const m = new Map((d?.byType ?? []).map((r) => [r.type, { type: r.type, n: num(r.n), total: num(r.total) }]));49    return TRANSACTION_TYPES.map((t) => m.get(t) ?? { type: t, n: 0, total: 0 });50  }, [d]);5152  const anchor = q.updatedAt;53  const daily = React.useMemo(() => {54    const rows = new Map<string, Record<string, number | string>>();55    for (let i = days - 1; i >= 0; i--) {56      const day = new Date(anchor - i * 86400_000).toISOString().slice(0, 10);57      rows.set(day, { day, bet: 0, win: 0, rewards: 0, grants: 0, admin: 0 });58    }59    for (const r of d?.daily ?? []) {60      const day = isoDay(r.day);61      const row = rows.get(day);62      if (!row) continue;63      const k = groupOf(r.type);64      row[k] = num(row[k]) + num(r.total);65    }66    return Array.from(rows.values());67  }, [d, days, anchor]);6869  const net = byType.reduce((a, r) => a + r.total, 0);70  const mismatches = d ? num(d.invariant.mismatches) : 0;7172  const distribution = React.useMemo(() => {73    const m = new Map((d?.balanceDistribution ?? []).map((r) => [num(r.bucket), num(r.n)]));74    return Array.from({ length: 11 }, (_, i) => {75      const b = i + 1;76      const label = b === 11 ? "100K+" : `${(b - 1) * 10}K–${b * 10}K`;77      return { bucket: b, label, n: m.get(b) ?? 0 };78    });79  }, [d]);8081  return (82    <>83      <PageHeader84        title="Economy"85        description="Supply of Spinza Credits, ledger flows by type and the wallet invariant. Every figure is fictional currency."86        actions={87          <>88            <SegmentedControl value={days} onChange={setDays} items={DAYS.map((v) => ({ value: v, label: `${v}d` }))} />89            <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />90          </>91        }92      />9394      {q.error && !d ? (95        <ErrorState error={q.error} onRetry={() => void q.refresh()} />96      ) : !d ? (97        <div className="space-y-4">98          <TileSkeleton count={5} cols={5} />99          <Panel>100            <TableSkeleton rows={9} cols={5} />101          </Panel>102        </div>103      ) : (104        <div className={cn("space-y-4", q.stale && "opacity-70")}>105          <StatGrid cols={5}>106            <StatTile label="Circulating supply" value={compact(d.supply.circulating)} sub={sc(d.supply.circulating)} tone="accent" />107            <StatTile label="Lifetime granted" value={compact(d.supply.granted)} sub={sc(d.supply.granted)} />108            <StatTile label="Lifetime wagered" value={compact(d.supply.wagered)} sub={sc(d.supply.wagered)} />109            <StatTile label="Lifetime won" value={compact(d.supply.won)} sub={`${sc(d.supply.won)} · RTP ${pct(num(d.supply.wagered) ? num(d.supply.won) / num(d.supply.wagered) : null)}`} />110            <StatTile111              label="Wallet invariant"112              value={113                <span className="inline-flex items-center gap-1.5">114                  {mismatches === 0 ? <CheckCircle2 className="h-5 w-5" /> : <XCircle className="h-5 w-5" />}115                  {mismatches === 0 ? "OK" : `${int(mismatches)} off`}116                </span>117              }118              tone={mismatches === 0 ? "success" : "danger"}119              sub={mismatches === 0 ? "Σ ledger = balance for every wallet" : "wallets whose ledger ≠ balance"}120            />121          </StatGrid>122123          <div className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">124            <Panel title={`Daily ledger flow · last ${days} days`} description="Signed SC per day; bets sit below the baseline">125              <ChartFrame series={SERIES} height={260}>126                <Bars data={daily} x="day" series={SERIES} stacked xFormat={(v) => shortDate(String(v))} yFormat={(v) => compact(v)} barSize={days > 30 ? 6 : 14} tooltipLabel={(l) => shortDate(String(l))} />127              </ChartFrame>128            </Panel>129            <Panel title="Balance distribution" description="Wallets per 10K SC band">130              <ChartFrame height={260}>131                <Histogram data={distribution} x="label" y="n" yFormat={(v) => int(v)} />132              </ChartFrame>133            </Panel>134          </div>135136          <Panel title={`Ledger by type · last ${days} days`} description={`Net flow ${signedSC(net)} across ${int(byType.reduce((a, r) => a + r.n, 0))} transactions`} padded={false}>137            <DataTable138              columns={typeCols}139              rows={byType}140              rowKey={(r) => r.type}141              dense142              footer={143                <tr className="border-t border-line text-[13px] font-semibold">144                  <td className="px-3 py-2" colSpan={2}>145                    Net146                  </td>147                  <td className="px-3 py-2 text-right tabular">{int(byType.reduce((a, r) => a + r.n, 0))}</td>148                  <td className={cn("px-3 py-2 text-right tabular", net >= 0 ? "text-success" : "text-danger")}>{signedSC(net)}</td>149                  <td />150                </tr>151              }152            />153          </Panel>154        </div>155      )}156    </>157  );158}159