ledger revanced

This commit is contained in:
NextJS
2026-05-11 06:34:15 +00:00
parent 6d2050cd24
commit 49d9f31182
13 changed files with 1719 additions and 302 deletions
+38 -3
View File
@@ -12,6 +12,8 @@ import {
} 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 { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view";
/** Bigint columns often arrive as strings from PostgREST / JSON. */
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
@@ -59,8 +61,21 @@ type Props = {
matches: DbMatch[];
usersError: string | null;
matchesError: string | null;
transactions: DbTransaction[];
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;
@@ -99,8 +114,18 @@ export function AdminDashboard({
matches,
usersError,
matchesError,
transactions,
transactionsError,
ledgerGlobalSummary,
ledgerTransactionsFiltered,
ledgerTableRows,
ledgerTotalRowsInRange,
ledgerPage,
ledgerPageSize,
ledgerTotalPages,
ledgerSort,
ledgerOrder,
ledgerFrom,
ledgerTo,
statsBundle,
tab,
highlightId,
@@ -503,11 +528,21 @@ export function AdminDashboard({
</section>
) : tab === "ledger" ? (
<AdminLedger
transactions={transactions}
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">
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import {
buildDashboardHref,
type AdminDashboardTab,
} from "@/lib/dashboard-search-url";
import type { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view";
import type { DbUser } from "@/types/database";
type Props = {
@@ -12,6 +13,12 @@ type Props = {
highlightId: string | null;
participantRaw: string | null;
saveError: boolean;
ledgerFrom: string;
ledgerTo: string;
ledgerPage: number;
ledgerPageSize: number;
ledgerSort: LedgerSortKey;
ledgerOrder: LedgerSortOrder;
};
export function EditUserCcRcOverlay({
@@ -20,11 +27,27 @@ export function EditUserCcRcOverlay({
highlightId,
participantRaw,
saveError,
ledgerFrom,
ledgerTo,
ledgerPage,
ledgerPageSize,
ledgerSort,
ledgerOrder,
}: Props) {
const cancelHref = buildDashboardHref({
tab,
highlightId,
participantRaw,
...(tab === "ledger"
? {
ledgerFrom,
ledgerTo,
ledgerPage,
ledgerPageSize,
ledgerSort,
ledgerOrder,
}
: {}),
});
return (
@@ -80,6 +103,20 @@ export function EditUserCcRcOverlay({
name="participantRaw"
value={participantRaw ?? ""}
/>
{tab === "ledger" ? (
<>
<input type="hidden" name="ledgerFrom" value={ledgerFrom} />
<input type="hidden" name="ledgerTo" value={ledgerTo} />
<input type="hidden" name="ledgerPage" value={String(ledgerPage)} />
<input
type="hidden"
name="ledgerPageSize"
value={String(ledgerPageSize)}
/>
<input type="hidden" name="ledgerSort" value={ledgerSort} />
<input type="hidden" name="ledgerOrder" value={ledgerOrder} />
</>
) : null}
<div>
<label
htmlFor={`edit-cc-${user.id}`}
@@ -0,0 +1,194 @@
import { useMemo, useState } from "react";
const VB_W = 320;
const VB_H = 52;
const PAD_L = 2;
const PAD_R = 2;
const PAD_T = 4;
const PAD_B = 2;
function maxBigint(values: bigint[]): bigint {
let m = BigInt(0);
for (const v of values) if (v > m) m = v;
return m;
}
function buildChartGeometry(
cumulativeCoins: bigint[],
vbW: number,
vbH: number,
): {
lineD: string;
areaD: string;
xs: number[];
ys: number[];
} {
const n = cumulativeCoins.length;
if (n === 0) return { lineD: "", areaD: "", xs: [], ys: [] };
const max = maxBigint(cumulativeCoins);
const innerW = vbW - PAD_L - PAD_R;
const innerH = vbH - PAD_T - PAD_B;
const maxN = max === BigInt(0) ? 1 : Number(max);
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i < n; i++) {
const x =
PAD_L + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const num = Number(cumulativeCoins[i]);
const yRatio = max === BigInt(0) ? 0 : num / maxN;
const y = PAD_T + innerH - yRatio * innerH;
xs.push(x);
ys.push(y);
}
const pts = xs.map((x, i) => `${x.toFixed(1)},${ys[i]!.toFixed(1)}`);
const lineD = `M ${pts.join(" L ")}`;
const x0 = xs[0]!;
const x1 = xs[xs.length - 1]!;
const yB = vbH - PAD_B;
const areaD = `${lineD} L ${x1.toFixed(1)},${yB.toFixed(1)} L ${x0.toFixed(1)},${yB.toFixed(1)} Z`;
return { lineD, areaD, xs, ys };
}
function indexFromPointerX(
relX: number,
width: number,
n: number,
): number {
if (n <= 0) return 0;
if (width <= 0) return 0;
const t = Math.min(1, Math.max(0, relX / width));
const maxI = Math.max(0, n - 1);
return maxI === 0 ? 0 : Math.round(t * maxI);
}
type Props = {
dates: string[];
cumulativeCoins: bigint[];
/** Short description for screen readers, e.g. "Entry fee cumulative in range". */
ariaLabel: string;
/** Sets `currentColor` for stroke and tinted fill (Tailwind text-* on wrapper). */
className: string;
};
/**
* Minimal area + line chart: cumulative metric vs UTC day index in range.
* Y uses numeric ratio (safe for typical coin totals in admin ranges).
*/
export function LedgerRangeCumulativeChart({
dates,
cumulativeCoins,
ariaLabel,
className,
}: Props) {
const { lineD, areaD, xs, ys } = useMemo(
() => buildChartGeometry(cumulativeCoins, VB_W, VB_H),
[cumulativeCoins],
);
const [hover, setHover] = useState<{
idx: number;
clientX: number;
clientY: number;
} | null>(null);
const n = cumulativeCoins.length;
if (dates.length === 0 || n === 0) {
return (
<p className="mt-2 text-[10px] text-zinc-400 dark:text-zinc-500">
No days in range for chart.
</p>
);
}
const first = dates[0]!;
const last = dates[dates.length - 1]!;
return (
<figure className={`mt-3 ${className}`} aria-label={ariaLabel}>
<div
className="relative cursor-crosshair touch-none"
style={{ touchAction: "none" }}
onPointerMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const idx = indexFromPointerX(
e.clientX - rect.left,
rect.width,
n,
);
setHover({ idx, clientX: e.clientX, clientY: e.clientY });
}}
onPointerLeave={() => setHover(null)}
onPointerCancel={() => setHover(null)}
>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
className="h-12 w-full overflow-visible"
preserveAspectRatio="none"
>
{areaD ? (
<path d={areaD} className="fill-current opacity-[0.12]" />
) : null}
{lineD ? (
<path
d={lineD}
fill="none"
className="stroke-current"
strokeWidth={1.75}
vectorEffect="non-scaling-stroke"
/>
) : null}
{hover != null &&
xs[hover.idx] != null &&
ys[hover.idx] != null ? (
<g className="pointer-events-none">
<line
x1={xs[hover.idx]}
y1={PAD_T}
x2={xs[hover.idx]}
y2={VB_H - PAD_B}
className="stroke-current opacity-35"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={xs[hover.idx]}
cy={ys[hover.idx]}
r={4}
className="fill-current stroke-white dark:stroke-zinc-950"
strokeWidth={1.25}
/>
</g>
) : null}
</svg>
{hover != null ? (
<div
role="tooltip"
className="pointer-events-none fixed z-[100] max-w-[min(18rem,calc(100vw-1rem))] rounded-md border border-zinc-200 bg-white px-2.5 py-1.5 text-left text-xs shadow-lg dark:border-zinc-600 dark:bg-zinc-900"
style={{
left: hover.clientX,
top: hover.clientY,
transform: "translate(-50%, calc(-100% - 10px))",
}}
>
<p className="font-mono text-[10px] text-zinc-500 dark:text-zinc-400">
{dates[hover.idx]}{" "}
<span className="text-zinc-400 dark:text-zinc-500">UTC</span>
</p>
<p className="mt-0.5 tabular-nums font-semibold text-zinc-900 dark:text-zinc-50">
{cumulativeCoins[hover.idx]!.toLocaleString("en-US")}{" "}
<span className="font-normal font-sans text-zinc-500 dark:text-zinc-400">
coins
</span>
</p>
</div>
) : null}
</div>
<figcaption className="mt-0.5 flex justify-between gap-2 font-mono text-[10px] text-zinc-500 tabular-nums dark:text-zinc-400">
<span>{first}</span>
<span className="text-zinc-400 dark:text-zinc-500">UTC</span>
<span>{last}</span>
</figcaption>
</figure>
);
}