ledger revanced
This commit is contained in:
@@ -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">
|
||||
|
||||
+698
-288
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user