Add Bank accounts, transactions, and pending transfer views
CI / checks (push) Successful in 7m50s

This commit is contained in:
2026-10-13 11:50:26 -04:00
parent d6147077d7
commit e85dc58130
19 changed files with 1888 additions and 7 deletions
+13
View File
@@ -0,0 +1,13 @@
import { error, json } from '@sveltejs/kit';
import { readBankQuery } from '$lib/bank.js';
import { bankOverview } from '$lib/server/fleet/bank-overview.js';
export function GET({ url }: { url: URL }) {
let query;
try {
query = readBankQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid bank filters.');
}
return json(bankOverview(query), { headers: { 'Cache-Control': 'no-store' } });
}
+13
View File
@@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { readBankQuery } from '$lib/bank.js';
import { bankOverview } from '$lib/server/fleet/bank-overview.js';
export function load({ url }: { url: URL }) {
let query;
try {
query = readBankQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid bank filters.');
}
return { report: bankOverview(query) };
}
+693
View File
@@ -0,0 +1,693 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
import { bankAttentionAgeMs, type BankOverview, type BankQuery } from '$lib/bank.js';
import { contextHref } from '$lib/navigation-context.js';
import { pollVisible } from '$ui/poll.js';
import { serverTitle } from '$ui/server-labels.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import Tabs from '$ui/components/primitives/Tabs.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import ServerIdentity from '$ui/components/ServerIdentity.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import BankPagination from '$ui/components/bank/BankPagination.svelte';
import BankTransfers from '$ui/components/bank/BankTransfers.svelte';
import BankPendingDetails from '$ui/components/bank/BankPendingDetails.svelte';
import '$ui/styles/bank.css';
let { data }: { data: import('./$types').PageData } = $props();
const number = new Intl.NumberFormat('en-US');
let snapshot = $state<BankOverview | null>(null);
const report = $derived(snapshot ?? data.report);
let filters = $state<BankQuery>(untrack(() => ({ ...data.report.query })));
let refreshError = $state('');
let navigationError = $state('');
let updatedAt = $state<number | null>(null);
let refreshing = $state(false);
let controller: AbortController | undefined;
let generation = 0;
const busy = $derived(!!$navigating);
const results = $derived(report[report.query.view]);
const tabs = $derived([
{ id: 'accounts', label: 'Accounts' },
{ id: 'transactions', label: 'Transactions' },
{ id: 'pending', label: 'Pending', badge: report.summary.pending || null }
]);
const serverOptions = $derived([
{ value: '', label: 'All servers' },
...[...new Set([...report.servers, ...(filters.server ? [filters.server] : [])])].map(
(server) => ({ value: server, label: serverTitle(server) })
)
]);
$effect(() => {
const initial = data.report;
untrack(() => {
snapshot = null;
filters = { ...initial.query };
refreshError = '';
});
});
function cancelRefresh() {
generation++;
controller?.abort();
refreshing = false;
}
beforeNavigate(cancelRefresh);
afterNavigate(() => {
updatedAt = Date.now();
navigationError = '';
});
async function refresh(signal?: AbortSignal) {
if (busy || refreshing) return;
const current = ++generation;
const request = new AbortController();
controller = request;
const abort = () => request.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) request.abort();
const timeout = setTimeout(abort, 30_000);
refreshing = true;
try {
const response = await fetch(`/api/bank${$page.url.search}`, {
signal: request.signal
});
if (!response.ok) throw new Error('Bank refresh failed.');
const next: BankOverview = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation) refreshError = 'Bank updates paused.';
return false;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
if (current === generation) refreshing = false;
}
}
onMount(() => {
updatedAt = Date.now();
const stop = pollVisible(refresh, 30_000, false);
return () => {
stop();
cancelRefresh();
};
});
async function navigate(changes: Record<string, string | number | null>) {
const params = new URLSearchParams($page.url.searchParams);
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') params.delete(key);
else params.set(key, String(value));
}
navigationError = '';
try {
await goto(`/bank?${params}`, { keepFocus: true, noScroll: true });
} catch {
navigationError = 'Could not load this bank view. Please try again.';
}
}
function changeView(view: string) {
void navigate({
view,
offset: null,
balance: null,
sort: null,
game: null,
server: null,
operation: null,
from: null,
to: null
});
}
function applyFilters(event: SubmitEvent) {
event.preventDefault();
void navigate({
q: filters.q,
balance: filters.balance,
sort: filters.sort,
game: filters.game,
server: filters.server,
operation: filters.operation,
from: filters.from,
to: filters.to,
offset: null
});
}
const openPlayer = (guid: string) => {
void navigate({ player: guid, historyOffset: null });
};
function setDrawerOpen(open: boolean) {
if (!open && report.query.player && !busy)
void navigate({ player: null, historyOffset: null });
}
const isOlder = (createdAt: string) =>
Date.parse(report.checkedAt) - Date.parse(createdAt) >= bankAttentionAgeMs;
const capacity = (balance: number) =>
report.maximum ? Math.round((balance / report.maximum) * 100) : 0;
</script>
<svelte:head><title>Bank · Electric Cherry</title></svelte:head>
<div class="bank-page">
<SectionHeader
title="Bank"
eyebrow="Players"
subtitle="Shared player balances across Black Ops I & II."
>
{#snippet actions()}<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 30 s"
retryDisabled={refreshing || busy}
onRetry={() => void refresh()}
/><Button size="sm" loading={refreshing} disabled={busy} onclick={() => void refresh()}
>Refresh</Button
>{/snippet}
</SectionHeader>
<dl class="summary">
<div>
<dt>Total banked</dt>
<dd>{number.format(report.summary.totalBanked)} <small>pts</small></dd>
</div>
<div>
<dt>Funded accounts</dt>
<dd>{number.format(report.summary.fundedAccounts)}</dd>
</div>
<div>
<dt>Transfers · last 24 hours</dt>
<dd>{number.format(report.summary.transfers24h)}</dd>
</div>
<div>
<dt>Pending transfers</dt>
<dd>
<button class="summary-link" onclick={() => changeView('pending')}
>{number.format(report.summary.pending)}</button
>
</dd>
</div>
</dl>
{#if report.maximum === null}<p class="notice">
The bank deposit limit is unavailable. Balances and transfer history remain visible; new
deposits are disabled until the configuration is corrected.
</p>{/if}
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<Tabs
{tabs}
value={report.query.view}
onchange={changeView}
ariaLabel="Bank views"
panelId="bank-results"
idPrefix="bank-tab"
/>
<div
id="bank-results"
role="tabpanel"
aria-labelledby={`bank-tab-${report.query.view}`}
aria-busy={busy}
class="results"
>
<form class="filters" method="GET" action="/bank" onsubmit={applyFilters}>
<input type="hidden" name="view" value={report.query.view} />
<div class="search">
<Field label="Player name or GUID"
><Input
name="q"
type="search"
bind:value={filters.q}
maxlength={100}
placeholder="Search players…"
/></Field
>
</div>
{#if report.query.view === 'accounts'}
<Field label="Accounts"
><Select
name="balance"
bind:value={filters.balance}
options={[
{ value: '', label: 'All accounts' },
{ value: 'funded', label: 'Funded accounts' },
{
value: 'limit',
label: 'At or above limit',
disabled: report.maximum === null
},
{ value: 'pending', label: 'Pending transfers' }
]}
/></Field
>
<Field label="Sort by"
><Select
name="sort"
bind:value={filters.sort}
options={[
{ value: 'balance', label: 'Highest balance' },
{ value: 'recent', label: 'Recent activity' }
]}
/></Field
>
{:else}
<Field label="Game"
><Select
name="game"
bind:value={filters.game}
options={[
{ value: '', label: 'All games' },
{ value: 't5', label: 'Black Ops I' },
{ value: 't6', label: 'Black Ops II' }
]}
/></Field
>
<Field label="Server"
><Select
name="server"
bind:value={filters.server}
options={serverOptions}
/></Field
>
<Field label="Transfer"
><Select
name="operation"
bind:value={filters.operation}
options={[
{ value: '', label: 'All transfers' },
{ value: 'deposit', label: 'Deposits' },
{ value: 'withdraw', label: 'Withdrawals' }
]}
/></Field
>
<Field label="From (UTC)"
><Input
name="from"
type="date"
bind:value={filters.from}
max={filters.to || undefined}
/></Field
>
<Field label="Through (UTC)"
><Input
name="to"
type="date"
bind:value={filters.to}
min={filters.from || undefined}
/></Field
>
{/if}
<div class="filter-actions">
<Button type="submit" disabled={busy}>Apply</Button><Button
variant="ghost"
disabled={busy}
onclick={() => {
void navigate({
q: null,
balance: null,
sort: null,
game: null,
server: null,
operation: null,
from: null,
to: null,
offset: null
});
}}>Reset</Button
>
</div>
</form>
{#if report.query.view === 'accounts'}
<p class="hint">
Balances are shared across both games.{report.maximum !== null
? ` Account limit: ${number.format(report.maximum)} points.`
: ''}
</p>
{#if report.accounts.entries.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div
class="bank-table-scroll"
role="region"
aria-label="Bank accounts"
tabindex="0"
>
<table class="bank-table">
<caption class="sr-only">Player bank accounts</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Balance</th><th
scope="col">Bank capacity</th
><th scope="col">Last activity</th><th scope="col">Status</th></tr
></thead
>
<tbody
>{#each report.accounts.entries as account (account.guid)}
<tr>
<td
><button
class="bank-player"
onclick={() => openPlayer(account.guid)}
>{account.name ?? account.guid}</button
><small class="bank-id">{account.guid}</small></td
>
<td class="bank-number"
><strong>{number.format(account.balance)} pts</strong
>{#if account.available !== account.balance}<small
>{number.format(account.available)} available</small
>{/if}</td
>
<td
>{#if report.maximum !== null}<div class="capacity">
<meter
min="0"
max={report.maximum}
value={Math.min(
account.balance,
report.maximum
)}
aria-label={`${account.name ?? account.guid} bank capacity`}
></meter><span>{capacity(account.balance)}%</span>
</div>{:else}Unavailable{/if}</td
>
<td
>{#if account.lastActivity}<span
>{(account.lastAmount ?? 0) > 0
? 'Deposit'
: 'Withdrawal'}</span
><small
><RelativeTime
value={account.lastActivity}
/></small
>{:else}No completed transfers{/if}</td
>
<td
>{#if account.pendingOperation}<span
>Pending {account.pendingOperation === 'deposit'
? 'deposit'
: 'withdrawal'}</span
><small
>{number.format(account.pendingAmount ?? 0)} pts</small
>{:else if report.maximum !== null && account.balance >= report.maximum}At
limit{:else}Available{/if}</td
>
</tr>
{/each}</tbody
>
</table>
</div>
{:else}<EmptyState
title="No bank accounts found"
description="Accounts appear when players deposit points in game. Try changing your filters if you expected to see more."
/>{/if}
{:else if report.query.view === 'transactions'}
<p class="hint">
Completed deposits and withdrawals across your fleet. Game filters apply to
activity; account balances remain shared.
</p>
<BankTransfers
entries={report.transactions.entries}
source={$page.url}
onPlayer={openPlayer}
/>
{:else}
<p class="hint">
Oldest transfers first. Transfers waiting at least five minutes are highlighted for
attention.
</p>
{#if report.pending.entries.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div
class="bank-table-scroll"
role="region"
aria-label="Pending bank transfers"
tabindex="0"
>
<table class="bank-table">
<caption class="sr-only">Transfers awaiting settlement</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Transfer</th><th
scope="col">Server</th
><th scope="col">Waiting since</th><th scope="col">Details</th></tr
></thead
>
<tbody
>{#each report.pending.entries as transfer (transfer.guid)}
<tr>
<td
><button
class="bank-player"
onclick={() => openPlayer(transfer.guid)}
>{transfer.name ?? transfer.guid}</button
><small class="bank-id">{transfer.guid}</small></td
>
<td class="bank-number"
><strong>{number.format(transfer.amount)} pts</strong><small
>{transfer.operation === 'deposit'
? 'Deposit'
: 'Withdrawal'}</small
></td
>
<td><ServerIdentity name={transfer.server} /></td>
<td
><RelativeTime
value={transfer.createdAt}
/>{#if isOlder(transfer.createdAt)}<small
class="bank-warning">Waiting over 5 minutes</small
>{/if}</td
>
<td
><details>
<summary>Why pending?</summary>
<div class="pending-expanded">
<BankPendingDetails {transfer} source={$page.url} />
</div>
</details></td
>
</tr>
{/each}</tbody
>
</table>
</div>
{:else}<EmptyState
title="No pending transfers"
description="No transfers are waiting for settlement in this view."
/>{/if}
{/if}
<BankPagination
total={results.total}
offset={results.offset}
disabled={busy}
onchange={(offset) => {
void navigate({ offset });
}}
label={report.query.view === 'accounts' ? 'accounts' : 'transfers'}
/>
</div>
</div>
<Dialog
drawer
bind:open={() => !!report.query.player, setDrawerOpen}
title={report.detail?.account.name ?? (report.query.player || 'Player bank')}
description="Shared bank account across Black Ops I & II."
>
{#if report.detail}
{@const detail = report.detail}
<div class="account-detail">
<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 30 s"
retryDisabled={refreshing || busy}
onRetry={() => void refresh()}
/>
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<CopyValue value={detail.account.guid} label="Player GUID" />
<dl class="detail-summary">
<div>
<dt>Balance</dt>
<dd>{number.format(detail.account.balance)} pts</dd>
</div>
<div>
<dt>Available</dt>
<dd>{number.format(detail.account.available)} pts</dd>
</div>
</dl>
{#if detail.account.name}<Button
size="sm"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(detail.account.guid)}`,
$page.url
)}>View player profile</Button
>{/if}
{#if detail.pending}<section class="pending-card">
<h3>
Pending {detail.pending.operation === 'deposit' ? 'deposit' : 'withdrawal'} ·
{number.format(detail.pending.amount)} pts
</h3>
<BankPendingDetails transfer={detail.pending} source={$page.url} />
</section>{/if}
<h3>Transaction history</h3>
<BankTransfers entries={detail.history.entries} source={$page.url} compact />
<BankPagination
total={detail.history.total}
offset={detail.history.offset}
disabled={busy}
onchange={(historyOffset) => {
void navigate({ historyOffset });
}}
label="player transfers"
/>
</div>
{:else}<EmptyState
title="Player bank not found"
description="There is no bank or player record for this GUID."
/>{/if}
{#snippet actions()}<Button onclick={() => setDrawerOpen(false)}>Close</Button>{/snippet}
</Dialog>
<style>
.bank-page,
.results,
.account-detail {
display: grid;
gap: var(--space-5);
min-width: 0;
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
margin: 0;
}
.summary > div {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
dt {
color: var(--text-secondary);
font-size: var(--text-sm);
}
dd {
margin: var(--space-2) 0 0;
font-size: var(--text-2xl);
font-weight: var(--weight-semibold);
font-variant-numeric: tabular-nums;
}
dd small {
font-size: var(--text-sm);
font-weight: var(--weight-regular);
color: var(--text-secondary);
}
.summary-link {
padding: 0;
border: 0;
background: none;
color: var(--accent-primary-hover);
font: inherit;
cursor: pointer;
}
.filters {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: var(--space-3);
}
.search {
flex: 1 1 240px;
}
.filter-actions {
display: flex;
gap: var(--space-2);
}
.hint {
margin: 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.capacity {
display: flex;
align-items: center;
gap: var(--space-2);
font-variant-numeric: tabular-nums;
}
meter {
width: 100px;
height: 12px;
accent-color: var(--text-secondary);
}
meter::-webkit-meter-bar {
background: var(--surface-raised);
border: 0;
}
meter::-webkit-meter-optimum-value {
background: var(--text-secondary);
}
meter::-moz-meter-bar {
background: var(--text-secondary);
}
summary {
color: var(--accent-primary-hover);
white-space: nowrap;
}
.pending-expanded {
min-width: 250px;
max-width: 380px;
padding-top: var(--space-3);
}
.detail-summary {
display: flex;
flex-wrap: wrap;
gap: var(--space-6);
margin: 0;
}
.detail-summary dd {
font-size: var(--text-xl);
}
h3 {
margin: 0;
font-size: var(--text-md);
}
.pending-card {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
}
.notice {
margin: 0;
padding: var(--space-4);
border: 1px solid var(--state-warning);
border-radius: var(--radius-md);
color: var(--state-warning);
font-size: var(--text-sm);
}
@media (max-width: 1100px) {
.summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.summary {
gap: var(--space-2);
}
.summary > div {
padding: var(--space-3);
}
dd {
font-size: var(--text-xl);
}
}
</style>
@@ -164,8 +164,13 @@
description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute."
/>
{/if}
<section aria-labelledby="bank-title">
<h2 id="bank-title">Bank</h2>
<section class="bank-card" aria-labelledby="bank-title">
<div class="bank-heading">
<h2 id="bank-title">Bank</h2>
<Button size="sm" href={`/bank?player=${encodeURIComponent(profile.guid)}`}
>View bank history</Button
>
</div>
<p>
<strong>{number.format(data.bank.balance)} points</strong> shared across Black Ops I and II.
</p>
@@ -184,7 +189,7 @@
></thead
>
<tbody
>{#each data.bank.entries as entry (entry.id)}
>{#each data.bank.entries.slice(0, 3) as entry (entry.id)}
<tr
><td>{$timeFormat.dateTime(entry.createdAt)}</td><td
>{serverTitle(entry.server)}</td
@@ -499,6 +504,22 @@
</Dialog>
<style>
.bank-card {
padding: var(--space-5);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.bank-heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
.bank-heading h2 {
margin: 0;
}
.profile {
display: flex;
flex-direction: column;