ledger revanced
This commit is contained in:
+2
-2
@@ -3,9 +3,9 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 2613",
|
||||
"dev": "next dev -p 2614",
|
||||
"build": "next build",
|
||||
"start": "next start -p 2613",
|
||||
"start": "next start -p 2614",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type AdminDashboardTab,
|
||||
} from "@/lib/dashboard-search-url";
|
||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||
import { normalizeLedgerPageSize } from "@/lib/ledger-table-view";
|
||||
|
||||
function parseScoreField(raw: FormDataEntryValue | null): number | null {
|
||||
if (raw == null) return null;
|
||||
@@ -41,7 +42,42 @@ export async function updateUserCcRc(formData: FormData) {
|
||||
const participant =
|
||||
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) {
|
||||
redirect(buildDashboardHref(base));
|
||||
|
||||
+105
-8
@@ -2,6 +2,15 @@ import { AdminDashboard } from "@/components/admin-dashboard";
|
||||
import { AdminHeader } from "@/components/admin-header";
|
||||
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
|
||||
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 type { AdminDashboardTab } from "@/lib/dashboard-search-url";
|
||||
import {
|
||||
@@ -10,6 +19,16 @@ import {
|
||||
} from "@/lib/matchmaker-log-source";
|
||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||
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";
|
||||
|
||||
@@ -31,6 +50,12 @@ export default async function Home({
|
||||
edit?: string | string[];
|
||||
saveError?: 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;
|
||||
@@ -58,8 +83,18 @@ export default async function Home({
|
||||
let configError: string | null = null;
|
||||
let usersError: string | null = null;
|
||||
let matchesError: string | null = null;
|
||||
let transactions: DbTransaction[] = [];
|
||||
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 =
|
||||
null;
|
||||
|
||||
@@ -108,15 +143,61 @@ export default async function Home({
|
||||
statsBundle = await loadDashboardStatsBundle(supabase);
|
||||
|
||||
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")
|
||||
.select("id, created_at, from, to, amount, remarks, match_id")
|
||||
.order("id", { ascending: false })
|
||||
.limit(5000);
|
||||
if (txRes.error) {
|
||||
transactionsError = txRes.error.message;
|
||||
.gte("created_at", rangeStartIso)
|
||||
.lte("created_at", rangeEndIso)
|
||||
.order("created_at", { ascending: true })
|
||||
.order("id", { ascending: true })
|
||||
.limit(10000);
|
||||
|
||||
const [globalRes, filteredRes] = await Promise.all([
|
||||
fetchLedgerGlobalSummary(supabase),
|
||||
filteredQuery,
|
||||
]);
|
||||
|
||||
if (!globalRes.ok) {
|
||||
transactionsError = globalRes.message;
|
||||
} 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}
|
||||
usersError={usersError}
|
||||
matchesError={matchesError}
|
||||
transactions={transactions}
|
||||
transactionsError={transactionsError}
|
||||
statsBundle={statsBundle}
|
||||
tab={tab}
|
||||
@@ -154,6 +234,17 @@ export default async function Home({
|
||||
matchmakerSource={matchmakerSource}
|
||||
matchmakerContent={matchmakerContent}
|
||||
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 ? (
|
||||
<EditUserCcRcOverlay
|
||||
@@ -162,6 +253,12 @@ export default async function Home({
|
||||
highlightId={highlightId}
|
||||
participantRaw={participantRaw}
|
||||
saveError={saveError}
|
||||
ledgerFrom={ledgerFrom}
|
||||
ledgerTo={ledgerTo}
|
||||
ledgerPage={ledgerPageView}
|
||||
ledgerPageSize={ledgerPageSizeView}
|
||||
ledgerSort={ledgerSortKeyView}
|
||||
ledgerOrder={ledgerSortOrderView}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MatchmakerLogSource } from "@/lib/matchmaker-log-source";
|
||||
import { DEFAULT_LEDGER_PAGE_SIZE } from "@/lib/ledger-table-view";
|
||||
|
||||
export type AdminDashboardTab =
|
||||
| "dashboard"
|
||||
@@ -15,6 +16,14 @@ export type DashboardUrlQuery = {
|
||||
saveError?: boolean;
|
||||
/** When tab is matchmaker: `raw` selects matchmaker.log; omit or processed → history.log */
|
||||
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. */
|
||||
@@ -29,6 +38,26 @@ export function buildDashboardHref(q: DashboardUrlQuery): string {
|
||||
if (q.tab === "matchmaker" && q.matchmakerSource === "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)) {
|
||||
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