ledger revanced
This commit is contained in:
+2
-2
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+682
-272
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user