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
+2 -2
View File
@@ -3,9 +3,9 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 2613", "dev": "next dev -p 2614",
"build": "next build", "build": "next build",
"start": "next start -p 2613", "start": "next start -p 2614",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
+37 -1
View File
@@ -8,6 +8,7 @@ import {
type AdminDashboardTab, type AdminDashboardTab,
} from "@/lib/dashboard-search-url"; } from "@/lib/dashboard-search-url";
import { createAdminSupabase } from "@/lib/supabase/admin"; import { createAdminSupabase } from "@/lib/supabase/admin";
import { normalizeLedgerPageSize } from "@/lib/ledger-table-view";
function parseScoreField(raw: FormDataEntryValue | null): number | null { function parseScoreField(raw: FormDataEntryValue | null): number | null {
if (raw == null) return null; if (raw == null) return null;
@@ -41,7 +42,42 @@ export async function updateUserCcRc(formData: FormData) {
const participant = const participant =
participantRaw === "" ? null : participantRaw; participantRaw === "" ? null : participantRaw;
const base = { tab, highlightId, participantRaw: participant }; const ledgerFromRaw = String(formData.get("ledgerFrom") ?? "").trim();
const ledgerToRaw = String(formData.get("ledgerTo") ?? "").trim();
const ledgerPageRaw = String(formData.get("ledgerPage") ?? "").trim();
const ledgerPageSizeRaw = String(
formData.get("ledgerPageSize") ?? "",
).trim();
const ledgerSortRaw = String(formData.get("ledgerSort") ?? "").trim();
const ledgerOrderRaw = String(formData.get("ledgerOrder") ?? "").trim();
const ledgerPageNum =
ledgerPageRaw === "" ? NaN : Number.parseInt(ledgerPageRaw, 10);
const base = {
tab,
highlightId,
participantRaw: participant,
...(tab === "ledger"
? {
ledgerFrom: ledgerFromRaw || null,
ledgerTo: ledgerToRaw || null,
ledgerPage:
Number.isInteger(ledgerPageNum) && ledgerPageNum > 1
? ledgerPageNum
: null,
ledgerSort: ledgerSortRaw || null,
ledgerOrder:
ledgerOrderRaw === "desc"
? ("desc" as const)
: ledgerOrderRaw === "asc"
? ("asc" as const)
: null,
ledgerPageSize: normalizeLedgerPageSize(
ledgerPageSizeRaw === "" ? null : ledgerPageSizeRaw,
),
}
: {}),
};
if (!Number.isInteger(userId) || userId < 1) { if (!Number.isInteger(userId) || userId < 1) {
redirect(buildDashboardHref(base)); redirect(buildDashboardHref(base));
+105 -8
View File
@@ -2,6 +2,15 @@ import { AdminDashboard } from "@/components/admin-dashboard";
import { AdminHeader } from "@/components/admin-header"; import { AdminHeader } from "@/components/admin-header";
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay"; import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
import { loadDashboardStatsBundle } from "@/lib/dashboard-stats"; import { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
import {
normalizeLedgerDateRange,
utcLast30DaysDateRange,
} from "@/lib/ledger-utc-date-range";
import { fetchLedgerGlobalSummary } from "@/lib/ledger-global-summary-server";
import {
serializeLedgerGlobalSummary,
type SerializedLedgerGlobalSummary,
} from "@/lib/ledger-integrity";
import { createAdminSupabase } from "@/lib/supabase/admin"; import { createAdminSupabase } from "@/lib/supabase/admin";
import type { AdminDashboardTab } from "@/lib/dashboard-search-url"; import type { AdminDashboardTab } from "@/lib/dashboard-search-url";
import { import {
@@ -10,6 +19,16 @@ import {
} from "@/lib/matchmaker-log-source"; } from "@/lib/matchmaker-log-source";
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
import type { DbMatch, DbTransaction, DbUser } from "@/types/database"; import type { DbMatch, DbTransaction, DbUser } from "@/types/database";
import {
normalizeLedgerPage,
normalizeLedgerPageSize,
normalizeLedgerSortKey,
normalizeLedgerSortOrder,
sliceLedgerPage,
sortLedgerTransactions,
type LedgerSortKey,
type LedgerSortOrder,
} from "@/lib/ledger-table-view";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -31,6 +50,12 @@ export default async function Home({
edit?: string | string[]; edit?: string | string[];
saveError?: string | string[]; saveError?: string | string[];
mklog?: string | string[]; mklog?: string | string[];
lfrom?: string | string[];
lto?: string | string[];
lpage?: string | string[];
lsize?: string | string[];
lsort?: string | string[];
lorder?: string | string[];
}>; }>;
}) { }) {
const sp = await searchParams; const sp = await searchParams;
@@ -58,8 +83,18 @@ export default async function Home({
let configError: string | null = null; let configError: string | null = null;
let usersError: string | null = null; let usersError: string | null = null;
let matchesError: string | null = null; let matchesError: string | null = null;
let transactions: DbTransaction[] = [];
let transactionsError: string | null = null; let transactionsError: string | null = null;
let ledgerGlobalSummary: SerializedLedgerGlobalSummary | null = null;
let ledgerTransactionsFiltered: DbTransaction[] = [];
let ledgerTableRows: DbTransaction[] = [];
let ledgerTotalRowsInRange = 0;
let ledgerPageView = 1;
let ledgerPageSizeView = normalizeLedgerPageSize(null);
let ledgerTotalPagesView = 1;
let ledgerSortKeyView: LedgerSortKey = "created_at";
let ledgerSortOrderView: LedgerSortOrder = "desc";
let ledgerFrom = utcLast30DaysDateRange().from;
let ledgerTo = utcLast30DaysDateRange().to;
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null = let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
null; null;
@@ -108,15 +143,61 @@ export default async function Home({
statsBundle = await loadDashboardStatsBundle(supabase); statsBundle = await loadDashboardStatsBundle(supabase);
if (tab === "ledger") { if (tab === "ledger") {
const txRes = await supabase const lfromRaw = firstSearchParam(sp.lfrom);
const ltoRaw = firstSearchParam(sp.lto);
const range = normalizeLedgerDateRange(lfromRaw, ltoRaw);
ledgerFrom = range.from;
ledgerTo = range.to;
const rangeStartIso = `${ledgerFrom}T00:00:00.000Z`;
const rangeEndIso = `${ledgerTo}T23:59:59.999Z`;
const filteredQuery = supabase
.from("transactions") .from("transactions")
.select("id, created_at, from, to, amount, remarks, match_id") .select("id, created_at, from, to, amount, remarks, match_id")
.order("id", { ascending: false }) .gte("created_at", rangeStartIso)
.limit(5000); .lte("created_at", rangeEndIso)
if (txRes.error) { .order("created_at", { ascending: true })
transactionsError = txRes.error.message; .order("id", { ascending: true })
.limit(10000);
const [globalRes, filteredRes] = await Promise.all([
fetchLedgerGlobalSummary(supabase),
filteredQuery,
]);
if (!globalRes.ok) {
transactionsError = globalRes.message;
} else { } else {
transactions = (txRes.data ?? []) as DbTransaction[]; ledgerGlobalSummary = serializeLedgerGlobalSummary(globalRes.data);
}
if (filteredRes.error) {
if (!transactionsError) {
transactionsError = filteredRes.error.message;
}
} else {
ledgerTransactionsFiltered = (filteredRes.data ??
[]) as DbTransaction[];
const pageRaw = normalizeLedgerPage(firstSearchParam(sp.lpage));
ledgerPageSizeView = normalizeLedgerPageSize(
firstSearchParam(sp.lsize),
);
ledgerSortKeyView = normalizeLedgerSortKey(
firstSearchParam(sp.lsort),
);
ledgerSortOrderView = normalizeLedgerSortOrder(
firstSearchParam(sp.lorder),
ledgerSortKeyView,
);
const sorted = sortLedgerTransactions(
ledgerTransactionsFiltered,
ledgerSortKeyView,
ledgerSortOrderView,
);
const slice = sliceLedgerPage(sorted, pageRaw, ledgerPageSizeView);
ledgerTableRows = slice.rows;
ledgerTotalRowsInRange = slice.totalRows;
ledgerPageView = slice.page;
ledgerTotalPagesView = slice.totalPages;
} }
} }
} }
@@ -145,7 +226,6 @@ export default async function Home({
matches={matches} matches={matches}
usersError={usersError} usersError={usersError}
matchesError={matchesError} matchesError={matchesError}
transactions={transactions}
transactionsError={transactionsError} transactionsError={transactionsError}
statsBundle={statsBundle} statsBundle={statsBundle}
tab={tab} tab={tab}
@@ -154,6 +234,17 @@ export default async function Home({
matchmakerSource={matchmakerSource} matchmakerSource={matchmakerSource}
matchmakerContent={matchmakerContent} matchmakerContent={matchmakerContent}
matchmakerError={matchmakerError} matchmakerError={matchmakerError}
ledgerGlobalSummary={ledgerGlobalSummary}
ledgerTransactionsFiltered={ledgerTransactionsFiltered}
ledgerTableRows={ledgerTableRows}
ledgerTotalRowsInRange={ledgerTotalRowsInRange}
ledgerPage={ledgerPageView}
ledgerPageSize={ledgerPageSizeView}
ledgerTotalPages={ledgerTotalPagesView}
ledgerSort={ledgerSortKeyView}
ledgerOrder={ledgerSortOrderView}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
/> />
{editUser ? ( {editUser ? (
<EditUserCcRcOverlay <EditUserCcRcOverlay
@@ -162,6 +253,12 @@ export default async function Home({
highlightId={highlightId} highlightId={highlightId}
participantRaw={participantRaw} participantRaw={participantRaw}
saveError={saveError} saveError={saveError}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
ledgerPage={ledgerPageView}
ledgerPageSize={ledgerPageSizeView}
ledgerSort={ledgerSortKeyView}
ledgerOrder={ledgerSortOrderView}
/> />
) : null} ) : null}
</> </>
+38 -3
View File
@@ -12,6 +12,8 @@ import {
} from "@/lib/dashboard-search-url"; } from "@/lib/dashboard-search-url";
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source"; import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import type { DbMatch, DbTransaction, DbUser } from "@/types/database"; 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. */ /** Bigint columns often arrive as strings from PostgREST / JSON. */
function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean { function matchHasRecordedWinner(winnerId: DbMatch["winner_id"]): boolean {
@@ -59,8 +61,21 @@ type Props = {
matches: DbMatch[]; matches: DbMatch[];
usersError: string | null; usersError: string | null;
matchesError: string | null; matchesError: string | null;
transactions: DbTransaction[];
transactionsError: 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; statsBundle: DashboardStatsBundle | null;
/** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */ /** From server `searchParams` so SSR and client markup match (do not use `useSearchParams` here). */
tab: AdminDashboardTab; tab: AdminDashboardTab;
@@ -99,8 +114,18 @@ export function AdminDashboard({
matches, matches,
usersError, usersError,
matchesError, matchesError,
transactions,
transactionsError, transactionsError,
ledgerGlobalSummary,
ledgerTransactionsFiltered,
ledgerTableRows,
ledgerTotalRowsInRange,
ledgerPage,
ledgerPageSize,
ledgerTotalPages,
ledgerSort,
ledgerOrder,
ledgerFrom,
ledgerTo,
statsBundle, statsBundle,
tab, tab,
highlightId, highlightId,
@@ -503,11 +528,21 @@ export function AdminDashboard({
</section> </section>
) : tab === "ledger" ? ( ) : tab === "ledger" ? (
<AdminLedger <AdminLedger
transactions={transactions} ledgerGlobalSummary={ledgerGlobalSummary}
transactionsScoped={ledgerTransactionsFiltered}
ledgerTableRows={ledgerTableRows}
ledgerTotalRowsInRange={ledgerTotalRowsInRange}
ledgerPage={ledgerPage}
ledgerPageSize={ledgerPageSize}
ledgerTotalPages={ledgerTotalPages}
ledgerSort={ledgerSort}
ledgerOrder={ledgerOrder}
users={users} users={users}
error={transactionsError} error={transactionsError}
highlightId={highlightId} highlightId={highlightId}
participantRaw={participantRaw} participantRaw={participantRaw}
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
/> />
) : ( ) : (
<section className="space-y-3"> <section className="space-y-3">
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,7 @@ import {
buildDashboardHref, buildDashboardHref,
type AdminDashboardTab, type AdminDashboardTab,
} from "@/lib/dashboard-search-url"; } from "@/lib/dashboard-search-url";
import type { LedgerSortKey, LedgerSortOrder } from "@/lib/ledger-table-view";
import type { DbUser } from "@/types/database"; import type { DbUser } from "@/types/database";
type Props = { type Props = {
@@ -12,6 +13,12 @@ type Props = {
highlightId: string | null; highlightId: string | null;
participantRaw: string | null; participantRaw: string | null;
saveError: boolean; saveError: boolean;
ledgerFrom: string;
ledgerTo: string;
ledgerPage: number;
ledgerPageSize: number;
ledgerSort: LedgerSortKey;
ledgerOrder: LedgerSortOrder;
}; };
export function EditUserCcRcOverlay({ export function EditUserCcRcOverlay({
@@ -20,11 +27,27 @@ export function EditUserCcRcOverlay({
highlightId, highlightId,
participantRaw, participantRaw,
saveError, saveError,
ledgerFrom,
ledgerTo,
ledgerPage,
ledgerPageSize,
ledgerSort,
ledgerOrder,
}: Props) { }: Props) {
const cancelHref = buildDashboardHref({ const cancelHref = buildDashboardHref({
tab, tab,
highlightId, highlightId,
participantRaw, participantRaw,
...(tab === "ledger"
? {
ledgerFrom,
ledgerTo,
ledgerPage,
ledgerPageSize,
ledgerSort,
ledgerOrder,
}
: {}),
}); });
return ( return (
@@ -80,6 +103,20 @@ export function EditUserCcRcOverlay({
name="participantRaw" name="participantRaw"
value={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> <div>
<label <label
htmlFor={`edit-cc-${user.id}`} 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>
);
}
+29
View File
@@ -1,4 +1,5 @@
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source"; import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
import { DEFAULT_LEDGER_PAGE_SIZE } from "@/lib/ledger-table-view";
export type AdminDashboardTab = export type AdminDashboardTab =
| "dashboard" | "dashboard"
@@ -15,6 +16,14 @@ export type DashboardUrlQuery = {
saveError?: boolean; saveError?: boolean;
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */ /** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
matchmakerSource?: MatchmakerLogSource | null; matchmakerSource?: MatchmakerLogSource | null;
/** Ledger tab: UTC date-only bounds (`lfrom` / `lto` query params). */
ledgerFrom?: string | null;
ledgerTo?: string | null;
ledgerPage?: number | null;
/** Ledger rows per page (`lsize`); omitted from URL when equal to default. */
ledgerPageSize?: number | null;
ledgerSort?: string | null;
ledgerOrder?: "asc" | "desc" | null;
}; };
/** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */ /** Build `/?…` for dashboard tabs, filters, and optional edit / error flags. */
@@ -29,6 +38,26 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
if (q.tab === "matchmaker" && q.matchmakerSource === "raw") { if (q.tab === "matchmaker" && q.matchmakerSource === "raw") {
p.set("mklog", "raw"); p.set("mklog", "raw");
} }
if (q.tab === "ledger") {
if (q.ledgerFrom) p.set("lfrom", q.ledgerFrom);
if (q.ledgerTo) p.set("lto", q.ledgerTo);
if (
q.ledgerPage != null &&
Number.isFinite(q.ledgerPage) &&
Math.trunc(q.ledgerPage) > 1
) {
p.set("lpage", String(Math.trunc(q.ledgerPage)));
}
if (q.ledgerSort) p.set("lsort", q.ledgerSort);
if (q.ledgerOrder) p.set("lorder", q.ledgerOrder);
if (
q.ledgerPageSize != null &&
Number.isFinite(q.ledgerPageSize) &&
Math.trunc(q.ledgerPageSize) !== DEFAULT_LEDGER_PAGE_SIZE
) {
p.set("lsize", String(Math.trunc(q.ledgerPageSize)));
}
}
if (q.editId != null && Number.isFinite(q.editId)) { if (q.editId != null && Number.isFinite(q.editId)) {
p.set("edit", String(q.editId)); p.set("edit", String(q.editId));
} }
+48
View File
@@ -0,0 +1,48 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DbTransaction } from "@/types/database";
import {
applyTransactionToMutableAccumulator,
createMutableLedgerAccumulator,
mutableAccumulatorToIntegrity,
sortedNonZeroUserNetPairs,
type LedgerGlobalSummary,
} from "@/lib/ledger-integrity";
const PAGE_SIZE = 5000;
/**
* Scans every transaction row (paginated) and builds full integrity + user nets.
* Server-only — call from Server Components / Route Handlers, not from client components.
*/
export async function fetchLedgerGlobalSummary(
supabase: SupabaseClient,
): Promise<{ ok: true; data: LedgerGlobalSummary } | { ok: false; message: string }> {
const acc = createMutableLedgerAccumulator();
let offset = 0;
for (;;) {
const { data, error } = await supabase
.from("transactions")
.select("id, created_at, from, to, amount, remarks, match_id")
.order("id", { ascending: true })
.range(offset, offset + PAGE_SIZE - 1);
if (error) {
return { ok: false, message: error.message };
}
const rows = (data ?? []) as DbTransaction[];
if (rows.length === 0) {
break;
}
for (const row of rows) {
applyTransactionToMutableAccumulator(acc, row);
}
offset += rows.length;
if (rows.length < PAGE_SIZE) {
break;
}
}
const integrity = mutableAccumulatorToIntegrity(acc);
const userNetEntries = sortedNonZeroUserNetPairs(acc.net);
return { ok: true, data: { integrity, userNetEntries } };
}
+210
View File
@@ -0,0 +1,210 @@
import type { DbTransaction } from "@/types/database";
export function txAmountToBigInt(raw: number | string): bigint {
if (typeof raw === "number") {
if (!Number.isFinite(raw)) return BigInt(0);
return BigInt(Math.trunc(raw));
}
const t = String(raw).trim();
if (t === "" || !/^-?\d+$/.test(t)) return BigInt(0);
try {
return BigInt(t);
} catch {
return BigInt(0);
}
}
function isEntryFeeRemark(remarks: string | null): boolean {
return (remarks ?? "").toLowerCase().trim() === "entry_fee";
}
export type LedgerIntegrity = {
mintTotal: bigint;
sinkTotal: bigint;
entryFeeCoinsTotal: bigint;
purchaseCoinsTotal: bigint;
entryHoldCoinsTotal: bigint;
rewardCoinsTotal: bigint;
sumUserNets: bigint;
balanced: boolean;
totalDebitVolume: bigint;
totalCreditVolume: bigint;
volumeBalanced: boolean;
};
export type MutableLedgerAccumulator = {
mintTotal: bigint;
sinkTotal: bigint;
entryFeeCoinsTotal: bigint;
purchaseCoinsTotal: bigint;
entryHoldCoinsTotal: bigint;
rewardCoinsTotal: bigint;
net: Map<number, bigint>;
totalDebitVolume: bigint;
totalCreditVolume: bigint;
};
export function createMutableLedgerAccumulator(): MutableLedgerAccumulator {
return {
mintTotal: BigInt(0),
sinkTotal: BigInt(0),
entryFeeCoinsTotal: BigInt(0),
purchaseCoinsTotal: BigInt(0),
entryHoldCoinsTotal: BigInt(0),
rewardCoinsTotal: BigInt(0),
net: new Map(),
totalDebitVolume: BigInt(0),
totalCreditVolume: BigInt(0),
};
}
export function applyTransactionToMutableAccumulator(
acc: MutableLedgerAccumulator,
row: DbTransaction,
): void {
const a = txAmountToBigInt(row.amount);
if (a === BigInt(0)) return;
if (isEntryFeeRemark(row.remarks)) {
acc.entryFeeCoinsTotal += a;
}
const remark = (row.remarks ?? "").toLowerCase().trim();
if (remark === "purchase") acc.purchaseCoinsTotal += a;
if (remark === "entry_hold") acc.entryHoldCoinsTotal += a;
if (remark === "reward") acc.rewardCoinsTotal += a;
if (row.from == null) {
acc.mintTotal += a;
} else {
acc.totalDebitVolume += a;
const prev = acc.net.get(row.from) ?? BigInt(0);
acc.net.set(row.from, prev - a);
}
if (row.to == null) {
acc.sinkTotal += a;
} else {
acc.totalCreditVolume += a;
const prev = acc.net.get(row.to) ?? BigInt(0);
acc.net.set(row.to, prev + a);
}
}
export function mutableAccumulatorToIntegrity(
acc: MutableLedgerAccumulator,
): LedgerIntegrity {
let sumUserNets = BigInt(0);
for (const v of acc.net.values()) {
sumUserNets += v;
}
const netIssued = acc.mintTotal - acc.sinkTotal;
const balanced = sumUserNets === netIssued;
const volumeBalanced =
acc.totalDebitVolume + acc.mintTotal ===
acc.totalCreditVolume + acc.sinkTotal;
return {
mintTotal: acc.mintTotal,
sinkTotal: acc.sinkTotal,
entryFeeCoinsTotal: acc.entryFeeCoinsTotal,
purchaseCoinsTotal: acc.purchaseCoinsTotal,
entryHoldCoinsTotal: acc.entryHoldCoinsTotal,
rewardCoinsTotal: acc.rewardCoinsTotal,
sumUserNets,
balanced,
totalDebitVolume: acc.totalDebitVolume,
totalCreditVolume: acc.totalCreditVolume,
volumeBalanced,
};
}
export function computeLedgerIntegrity(rows: DbTransaction[]): LedgerIntegrity {
const acc = createMutableLedgerAccumulator();
for (const row of rows) {
applyTransactionToMutableAccumulator(acc, row);
}
return mutableAccumulatorToIntegrity(acc);
}
/** Same ordering as the admin ledger user-net table. */
export function sortedNonZeroUserNetPairs(
net: Map<number, bigint>,
): [number, bigint][] {
return [...net.entries()]
.filter(([, v]) => v !== BigInt(0))
.sort((a, b) => {
const da = a[1] < BigInt(0) ? -a[1] : a[1];
const db = b[1] < BigInt(0) ? -b[1] : b[1];
if (da === db) return a[0] - b[0];
return db > da ? 1 : -1;
});
}
export type LedgerGlobalSummary = {
integrity: LedgerIntegrity;
userNetEntries: [number, bigint][];
};
export type SerializedLedgerIntegrity = {
mintTotal: string;
sinkTotal: string;
entryFeeCoinsTotal: string;
purchaseCoinsTotal: string;
entryHoldCoinsTotal: string;
rewardCoinsTotal: string;
sumUserNets: string;
balanced: boolean;
totalDebitVolume: string;
totalCreditVolume: string;
volumeBalanced: boolean;
};
export type SerializedLedgerGlobalSummary = {
integrity: SerializedLedgerIntegrity;
userNetEntries: { userId: number; net: string }[];
};
export function serializeLedgerGlobalSummary(
summary: LedgerGlobalSummary,
): SerializedLedgerGlobalSummary {
const i = summary.integrity;
return {
integrity: {
mintTotal: i.mintTotal.toString(),
sinkTotal: i.sinkTotal.toString(),
entryFeeCoinsTotal: i.entryFeeCoinsTotal.toString(),
purchaseCoinsTotal: i.purchaseCoinsTotal.toString(),
entryHoldCoinsTotal: i.entryHoldCoinsTotal.toString(),
rewardCoinsTotal: i.rewardCoinsTotal.toString(),
sumUserNets: i.sumUserNets.toString(),
balanced: i.balanced,
totalDebitVolume: i.totalDebitVolume.toString(),
totalCreditVolume: i.totalCreditVolume.toString(),
volumeBalanced: i.volumeBalanced,
},
userNetEntries: summary.userNetEntries.map(([userId, net]) => ({
userId,
net: net.toString(),
})),
};
}
export function parseSerializedLedgerGlobalSummary(
s: SerializedLedgerGlobalSummary,
): { integrity: LedgerIntegrity; userNets: [number, bigint][] } {
const i = s.integrity;
return {
integrity: {
mintTotal: BigInt(i.mintTotal),
sinkTotal: BigInt(i.sinkTotal),
entryFeeCoinsTotal: BigInt(i.entryFeeCoinsTotal),
purchaseCoinsTotal: BigInt(i.purchaseCoinsTotal),
entryHoldCoinsTotal: BigInt(i.entryHoldCoinsTotal),
rewardCoinsTotal: BigInt(i.rewardCoinsTotal),
sumUserNets: BigInt(i.sumUserNets),
balanced: i.balanced,
totalDebitVolume: BigInt(i.totalDebitVolume),
totalCreditVolume: BigInt(i.totalCreditVolume),
volumeBalanced: i.volumeBalanced,
},
userNets: s.userNetEntries.map(
(e) => [e.userId, BigInt(e.net)] as [number, bigint],
),
};
}
+78
View File
@@ -0,0 +1,78 @@
import { txAmountToBigInt } from "@/lib/ledger-integrity";
import { enumerateUtcDatesInclusive } from "@/lib/ledger-utc-date-range";
import type { DbTransaction } from "@/types/database";
type DayInc = { entryFee: bigint; purchase: bigint; entryHold: bigint };
function bucketForRemark(remarks: string | null): keyof DayInc | null {
const r = (remarks ?? "").toLowerCase().trim();
if (r === "entry_fee") return "entryFee";
if (r === "purchase") return "purchase";
if (r === "entry_hold") return "entryHold";
return null;
}
/**
* Per UTC day in the inclusive range, cumulative coin totals for scoped rows
* (same remark rules as {@link computeLedgerIntegrity} for these metrics).
*/
export function buildLedgerScopedDailyCumulativeSeries(
rows: DbTransaction[],
rangeFrom: string,
rangeTo: string,
): {
dates: string[];
entryFeeCumulative: bigint[];
purchaseCumulative: bigint[];
entryHoldCumulative: bigint[];
} {
const dates = enumerateUtcDatesInclusive(rangeFrom, rangeTo);
if (dates.length === 0) {
return {
dates: [],
entryFeeCumulative: [],
purchaseCumulative: [],
entryHoldCumulative: [],
};
}
const daySet = new Set(dates);
const inc = new Map<string, DayInc>();
for (const d of dates) {
inc.set(d, {
entryFee: BigInt(0),
purchase: BigInt(0),
entryHold: BigInt(0),
});
}
for (const row of rows) {
const day = row.created_at.slice(0, 10);
if (!daySet.has(day)) continue;
const key = bucketForRemark(row.remarks);
if (key == null) continue;
const a = txAmountToBigInt(row.amount);
const z = inc.get(day);
if (!z) continue;
z[key] += a;
}
let cFee = BigInt(0);
let cPur = BigInt(0);
let cHold = BigInt(0);
const entryFeeCumulative: bigint[] = [];
const purchaseCumulative: bigint[] = [];
const entryHoldCumulative: bigint[] = [];
for (const d of dates) {
const z = inc.get(d)!;
cFee += z.entryFee;
cPur += z.purchase;
cHold += z.entryHold;
entryFeeCumulative.push(cFee);
purchaseCumulative.push(cPur);
entryHoldCumulative.push(cHold);
}
return {
dates,
entryFeeCumulative,
purchaseCumulative,
entryHoldCumulative,
};
}
+176
View File
@@ -0,0 +1,176 @@
import { txAmountToBigInt } from "@/lib/ledger-integrity";
import type { DbTransaction } from "@/types/database";
export const LEDGER_PAGE_SIZE_OPTIONS = [10, 30, 60, 100, 250] as const;
export type LedgerPageSizeOption = (typeof LEDGER_PAGE_SIZE_OPTIONS)[number];
export const DEFAULT_LEDGER_PAGE_SIZE = 30;
export function normalizeLedgerPageSize(raw: string | null): number {
const n = Number((raw ?? "").trim());
if (
Number.isInteger(n) &&
(LEDGER_PAGE_SIZE_OPTIONS as readonly number[]).includes(n)
) {
return n;
}
return DEFAULT_LEDGER_PAGE_SIZE;
}
export type LedgerSortKey =
| "id"
| "created_at"
| "from"
| "to"
| "amount"
| "rc"
| "remarks"
| "match_id";
export type LedgerSortOrder = "asc" | "desc";
export function normalizeLedgerSortKey(raw: string | null): LedgerSortKey {
const t = (raw ?? "").trim().toLowerCase();
if (t === "id") return "id";
if (t === "created_at" || t === "created") return "created_at";
if (t === "from") return "from";
if (t === "to") return "to";
if (t === "amount") return "amount";
if (t === "rc") return "rc";
if (t === "remarks" || t === "remark") return "remarks";
if (t === "match_id" || t === "match") return "match_id";
return "created_at";
}
/** When `lorder` is omitted: newest-first for Created; other columns default to ascending. */
export function normalizeLedgerSortOrder(
raw: string | null,
sortKey: LedgerSortKey,
): LedgerSortOrder {
const t = (raw ?? "").trim().toLowerCase();
if (t === "desc") return "desc";
if (t === "asc") return "asc";
return sortKey === "created_at" ? "desc" : "asc";
}
export function normalizeLedgerPage(raw: string | null): number {
const n = Number((raw ?? "").trim());
if (!Number.isInteger(n) || n < 1) return 1;
return n;
}
function nullFromKey(v: number | null): number {
return v == null ? -9_999_999_999 : v;
}
function nullToKey(v: number | null): number {
return v == null ? 9_999_999_999 : v;
}
function nullMatchKey(v: number | null): number {
return v == null ? 9_999_999_999 : v;
}
function comparePrimary(
a: DbTransaction,
b: DbTransaction,
sort: LedgerSortKey,
): number {
switch (sort) {
case "id":
return a.id - b.id;
case "created_at": {
const sa = a.created_at ?? "";
const sb = b.created_at ?? "";
return sa < sb ? -1 : sa > sb ? 1 : 0;
}
case "from":
return nullFromKey(a.from) - nullFromKey(b.from);
case "to":
return nullToKey(a.to) - nullToKey(b.to);
case "amount":
case "rc": {
const ba = txAmountToBigInt(a.amount);
const bb = txAmountToBigInt(b.amount);
return ba < bb ? -1 : ba > bb ? 1 : 0;
}
case "remarks": {
const ra = (a.remarks ?? "").toLowerCase();
const rb = (b.remarks ?? "").toLowerCase();
return ra < rb ? -1 : ra > rb ? 1 : 0;
}
case "match_id":
return nullMatchKey(a.match_id) - nullMatchKey(b.match_id);
default:
return 0;
}
}
/** Stable sort: primary column + tie-breaker `id` ascending. */
export function sortLedgerTransactions(
rows: DbTransaction[],
sort: LedgerSortKey,
order: LedgerSortOrder,
): DbTransaction[] {
const dir = order === "asc" ? 1 : -1;
const copy = [...rows];
copy.sort((a, b) => {
const c = comparePrimary(a, b, sort);
if (c !== 0) return c * dir;
return (a.id - b.id) * dir;
});
return copy;
}
export type LedgerPageSlice = {
rows: DbTransaction[];
totalRows: number;
page: number;
totalPages: number;
};
export function sliceLedgerPage(
sortedRows: DbTransaction[],
page: number,
pageSize: number = DEFAULT_LEDGER_PAGE_SIZE,
): LedgerPageSlice {
const totalRows = sortedRows.length;
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize) || 1);
const pageClamped = Math.min(Math.max(1, page), totalPages);
const start = (pageClamped - 1) * pageSize;
return {
rows: sortedRows.slice(start, start + pageSize),
totalRows,
page: pageClamped,
totalPages,
};
}
/** Page numbers with ellipses for gaps (e.g. 1 2 3 … 10). */
export function ledgerPaginationItems(
current: number,
totalPages: number,
): (number | "ellipsis")[] {
if (totalPages < 1) return [];
if (totalPages === 1) return [1];
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const set = new Set<number>();
set.add(1);
set.add(totalPages);
for (let d = -2; d <= 2; d++) {
const p = current + d;
if (p >= 1 && p <= totalPages) set.add(p);
}
const sorted = [...set].sort((a, b) => a - b);
const out: (number | "ellipsis")[] = [];
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i]! - sorted[i - 1]! > 1) {
out.push("ellipsis");
}
out.push(sorted[i]!);
}
return out;
}
+67
View File
@@ -0,0 +1,67 @@
/** Rolling last 30 UTC calendar days inclusive (today and the 29 prior days). */
export function utcLast30DaysDateRange(): { from: string; to: string } {
const now = new Date();
const to = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
const from = new Date(to);
from.setUTCDate(from.getUTCDate() - 29);
return {
from: from.toISOString().slice(0, 10),
to: to.toISOString().slice(0, 10),
};
}
/** Previous calendar month in UTC, as inclusive YYYY-MM-DD bounds. */
export function utcLastMonthDateRange(): { from: string; to: string } {
const now = new Date();
const y = now.getUTCFullYear();
const m = now.getUTCMonth();
const yearLast = m === 0 ? y - 1 : y;
const monthLast = m === 0 ? 11 : m - 1;
const fromDate = new Date(Date.UTC(yearLast, monthLast, 1));
const toDate = new Date(Date.UTC(yearLast, monthLast + 1, 0));
const from = fromDate.toISOString().slice(0, 10);
const to = toDate.toISOString().slice(0, 10);
return { from, to };
}
export function parseIsoDateOnly(s: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
const d = new Date(`${s}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? null : d;
}
/** Every UTC calendar day from `from` through `to` inclusive (YYYY-MM-DD). */
export function enumerateUtcDatesInclusive(from: string, to: string): string[] {
const a = parseIsoDateOnly(from);
const b = parseIsoDateOnly(to);
if (!a || !b || from > to) return [];
const out: string[] = [];
const cur = new Date(a.getTime());
const endMs = b.getTime();
while (cur.getTime() <= endMs) {
out.push(cur.toISOString().slice(0, 10));
cur.setUTCDate(cur.getUTCDate() + 1);
}
return out;
}
/** Normalize YYYY-MM-DD bounds; invalid inputs fall back to last 30 UTC days. */
export function normalizeLedgerDateRange(
fromRaw: string | null,
toRaw: string | null,
): { from: string; to: string } {
const fallback = utcLast30DaysDateRange();
let from = (fromRaw ?? "").trim();
let to = (toRaw ?? "").trim();
if (!parseIsoDateOnly(from) || !parseIsoDateOnly(to)) {
return fallback;
}
if (from > to) {
const t = from;
from = to;
to = t;
}
return { from, to };
}