Add AFK account and session overview (Refs #151)
CI / checks (push) Successful in 7m19s

This commit is contained in:
2026-10-17 17:52:03 -04:00
parent d723f23d67
commit 59f88ac33d
22 changed files with 2035 additions and 6 deletions
+143
View File
@@ -0,0 +1,143 @@
export const afkPageSize = 25;
export const afkLedgerKinds = [
'free_grant',
'purchase',
'reward',
'reserve',
'start',
'release',
'unresolved',
'consume',
'correction'
] as const;
export type AfkLedgerKind = (typeof afkLedgerKinds)[number];
export type AfkSessionState = 'reserved' | 'started' | 'settled' | 'unresolved' | 'corrected';
export interface AfkQuery {
view: 'accounts' | 'sessions' | 'ledger';
q: string;
balance: '' | 'saved' | 'reserved' | 'unresolved';
sort: 'saved' | 'available' | 'recent';
game: '' | 't5' | 't6';
server: string;
state: '' | AfkSessionState;
kind: '' | AfkLedgerKind;
from: string;
to: string;
offset: number;
player: string;
}
export function readAfkQuery(params: URLSearchParams): AfkQuery {
function choice<T extends string>(key: string, values: readonly T[], fallback: T): T {
const value = params.get(key) || fallback;
if (!values.includes(value as T)) throw new Error(`Invalid ${key} filter.`);
return value as T;
}
function text(key: string, max: number) {
const value = (params.get(key) ?? '').trim();
if (value.length > max) throw new Error(`${key} is too long.`);
return value;
}
function date(key: string) {
const value = text(key, 10);
if (
value &&
(!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString().slice(0, 10) !== value)
)
throw new Error('Use a valid date in YYYY-MM-DD format.');
return value;
}
const offset = Number(params.get('offset') || 0);
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('Invalid page offset.');
const from = date('from');
const to = date('to');
if (from && to && from > to) throw new Error('The end date must follow the start date.');
return {
view: choice('view', ['accounts', 'sessions', 'ledger'], 'accounts'),
q: text('q', 100),
balance: choice('balance', ['', 'saved', 'reserved', 'unresolved'], ''),
sort: choice('sort', ['saved', 'available', 'recent'], 'saved'),
game: choice('game', ['', 't5', 't6'], ''),
server: text('server', 256),
state: choice(
'state',
['', 'reserved', 'started', 'settled', 'unresolved', 'corrected'],
''
),
kind: choice('kind', ['', ...afkLedgerKinds], ''),
from,
to,
offset: Math.floor(offset / afkPageSize) * afkPageSize,
player: text('player', 128)
};
}
export interface AfkIdentity {
guid: string;
name: string | null;
}
export interface AfkAccount extends AfkIdentity {
freeSeconds: number;
savedSeconds: number;
availableSeconds: number;
reservedSeconds: number;
reservedSavedSeconds: number;
unresolvedSessions: number;
lastActivity: string | null;
freeRenewsAt: string | null;
}
export interface AfkSession extends AfkIdentity {
sessionId: string;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
state: AfkSessionState;
authorizedSeconds: number;
consumedSeconds: number | null;
chargedSeconds: number | null;
confirmedSeconds: number;
endReason: string | null;
createdAt: string;
startedAt: string | null;
endedAt: string | null;
}
export interface AfkLedgerEntry extends AfkIdentity {
id: number;
server: string;
game: string | null;
matchId: number | null;
matchAvailable: number;
kind: AfkLedgerKind;
freeDelta: number;
savedDelta: number;
freeSeconds: number;
savedSeconds: number;
operationId: string;
sessionId: string | null;
eventAt: string;
createdAt: string;
}
export interface AfkPage<T> {
entries: T[];
total: number;
offset: number;
}
export interface AfkOverview {
query: AfkQuery;
checkedAt: string;
capacitySeconds: number;
summary: {
accounts: number;
savedSeconds: number;
reservedSeconds: number;
unresolvedSessions: number;
};
servers: string[];
accounts: AfkPage<AfkAccount>;
sessions: AfkPage<AfkSession>;
ledger: AfkPage<AfkLedgerEntry>;
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { activityRedirectHref } from './activity-redirect.js';
const origin = 'https://electric-cherry.invalid';
const pagePath =
/^\/(?:$|activity|operations|autoscaling|account|bank|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/^\/(?:$|activity|operations|autoscaling|account|bank|afk|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
/** Only return to application pages, never an external URL or a mutation/API endpoint. */
export function safeReturnHref(value: string | null | undefined, fallback: string): string {
@@ -63,6 +63,7 @@ export function returnLabel(href: string): string {
provisioning: 'Server queues',
leaderboard: 'Leaderboard',
bank: 'Bank',
afk: 'AFK',
matches: 'Match History',
metrics: 'Metrics'
};
+1
View File
@@ -72,6 +72,7 @@ export const navigationGroups: NavigationGroup[] = [
{ label: 'Leaderboard', href: '/leaderboard', shortcut: 'l' },
{ label: 'Matches', href: '/matches' },
{ label: 'Bank', href: '/bank' },
{ label: 'AFK', href: '/afk' },
{ label: 'Moderation', href: '/moderation', moderation: true },
{ label: 'Usage', href: '/metrics', shortcut: 'm' }
]
+197
View File
@@ -0,0 +1,197 @@
import { db } from '../db/index.js';
import { afkPolicy } from './afk.js';
import {
afkPageSize,
type AfkAccount,
type AfkLedgerEntry,
type AfkOverview,
type AfkPage,
type AfkQuery,
type AfkSession
} from '../../afk.js';
const day = 86_400_000;
// A string GUID owns one reserve, including players who appear in both games.
const identities = `WITH identities AS (
SELECT guid, name FROM (
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS rank
FROM leaderboard_players WHERE game IN ('t5', 't6')
) WHERE rank = 1
)`;
// Mirror freeAvailability in afk.ts: held time never expires, and confirmed free
// consumption becomes available 24 hours after its durable stop receipt.
const accounts = `${identities}, session_totals AS (
SELECT guid,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN authorized_seconds ELSE 0 END) AS reserved,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN saved_seconds ELSE 0 END) AS reserved_saved,
SUM(CASE WHEN state = 'unresolved' THEN 1 ELSE 0 END) AS unresolved,
SUM(CASE WHEN state IN ('reserved','started','unresolved') THEN free_seconds
WHEN free_charge_at > ? THEN MIN(free_seconds, charged_seconds) ELSE 0 END) AS free_used,
MIN(CASE WHEN free_charge_at > ? AND MIN(free_seconds, charged_seconds) > 0 THEN free_charge_at END) AS next_free_at,
MAX(MAX(created_at, COALESCE(started_at, created_at), COALESCE(ended_at, created_at))) AS last_activity
FROM afk_sessions GROUP BY guid
), ledger_totals AS (
SELECT guid, MAX(created_at) AS last_activity FROM afk_ledger GROUP BY guid
), account_guids AS (
SELECT guid FROM afk_accounts UNION SELECT guid FROM session_totals UNION SELECT guid FROM ledger_totals
), balances AS (
SELECT g.guid, p.name,
CASE WHEN a.guid IS NOT NULL THEN MAX(0, ? - COALESCE(s.free_used, 0)) ELSE 0 END AS freeSeconds,
COALESCE(a.saved_seconds, 0) AS savedSeconds,
COALESCE(s.reserved, 0) AS reservedSeconds,
COALESCE(s.reserved_saved, 0) AS reservedSavedSeconds,
COALESCE(s.unresolved, 0) AS unresolvedSessions,
NULLIF(MAX(COALESCE(s.last_activity, ''), COALESCE(l.last_activity, '')), '') AS lastActivity,
strftime('%Y-%m-%dT%H:%M:%fZ', (s.next_free_at + ${day}) / 1000.0, 'unixepoch') AS freeRenewsAt
FROM account_guids g
LEFT JOIN afk_accounts a ON a.guid = g.guid
LEFT JOIN identities p ON p.guid = g.guid
LEFT JOIN session_totals s ON s.guid = g.guid
LEFT JOIN ledger_totals l ON l.guid = g.guid
), accounts AS (
SELECT *, freeSeconds + savedSeconds AS availableSeconds FROM balances
)`;
// Retained canonical server names identify the game after match/server removal.
const game = (alias: string) => `COALESCE(m.game, CASE
WHEN substr(${alias}.server, 1, 3) = 't5_' THEN 't5'
WHEN substr(${alias}.server, 1, 3) = 't6_' THEN 't6' END)`;
const sessions = `${identities}, sessions AS (
SELECT s.guid, p.name, s.session_id AS sessionId, s.server, ${game('s')} AS game,
s.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, s.state,
s.authorized_seconds AS authorizedSeconds, s.consumed_seconds AS consumedSeconds,
s.charged_seconds AS chargedSeconds, s.confirmed_seconds AS confirmedSeconds,
s.end_reason AS endReason, s.created_at AS createdAt, s.started_at AS startedAt, s.ended_at AS endedAt
FROM afk_sessions s LEFT JOIN identities p ON p.guid = s.guid
LEFT JOIN leaderboard_matches m ON m.id = s.match_id
)`;
const ledger = `${identities}, ledger AS (
SELECT l.guid, p.name, l.id, l.server, ${game('l')} AS game,
l.match_id AS matchId, m.id IS NOT NULL AS matchAvailable, l.kind,
l.free_delta AS freeDelta, l.saved_delta AS savedDelta,
l.free_seconds AS freeSeconds, l.saved_seconds AS savedSeconds,
l.operation_id AS operationId, l.session_id AS sessionId,
l.event_at AS eventAt, l.created_at AS createdAt
FROM afk_ledger l LEFT JOIN identities p ON p.guid = l.guid
LEFT JOIN leaderboard_matches m ON m.id = l.match_id
)`;
const emptyPage = <T>(): AfkPage<T> => ({ entries: [], total: 0, offset: 0 });
/** Read-only fleet report; reads never grant, settle, or update AFK balances. */
export function afkOverview(query: AfkQuery, now = new Date()): AfkOverview {
const handle = db();
return handle.transaction(() => {
const policy = afkPolicy();
const checkedAt = now.toISOString();
const accountValues = [now.getTime() - day, now.getTime() - day, policy.freeSeconds];
const summary = handle
.prepare(
`SELECT
(SELECT COUNT(*) FROM (SELECT guid FROM afk_accounts UNION SELECT guid FROM afk_sessions UNION SELECT guid FROM afk_ledger)) AS accounts,
(SELECT COALESCE(SUM(saved_seconds), 0) FROM afk_accounts) AS savedSeconds,
(SELECT COALESCE(SUM(authorized_seconds), 0) FROM afk_sessions WHERE state IN ('reserved','started','unresolved')) AS reservedSeconds,
(SELECT COUNT(*) FROM afk_sessions WHERE state = 'unresolved') AS unresolvedSessions`
)
.get() as AfkOverview['summary'];
const servers = (
handle
.prepare(
'SELECT server FROM afk_sessions UNION SELECT server FROM afk_ledger ORDER BY server'
)
.all() as { server: string }[]
).map((row) => row.server);
function page<T>(
cte: string,
table: string,
where: string[],
values: (string | number)[],
order: string
): AfkPage<T> {
const scope = `FROM ${table}${where.length ? ` WHERE ${where.join(' AND ')}` : ''}`;
const { total } = handle
.prepare(`${cte} SELECT COUNT(*) AS total ${scope}`)
.get(...values) as { total: number };
const offset = Math.min(
query.offset,
Math.max(0, Math.floor((total - 1) / afkPageSize) * afkPageSize)
);
const entries = handle
.prepare(`${cte} SELECT * ${scope} ORDER BY ${order} LIMIT ? OFFSET ?`)
.all(...values, afkPageSize, offset) as T[];
return { entries, total, offset };
}
const where: string[] = [];
const values: (string | number)[] = [];
if (query.q) {
where.push(
"(instr(lower(COALESCE(name, '')), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0)"
);
values.push(query.q, query.q);
}
if (query.player) {
where.push('guid = ?');
values.push(query.player);
}
let accountPage = emptyPage<AfkAccount>();
let sessionPage = emptyPage<AfkSession>();
let ledgerPage = emptyPage<AfkLedgerEntry>();
if (query.view === 'accounts') {
if (query.balance === 'saved') where.push('savedSeconds > 0');
if (query.balance === 'reserved') where.push('reservedSeconds > 0');
if (query.balance === 'unresolved') where.push('unresolvedSessions > 0');
const order =
query.sort === 'recent'
? 'lastActivity DESC, guid'
: query.sort === 'available'
? 'availableSeconds DESC, guid'
: 'savedSeconds DESC, guid';
accountPage = page(accounts, 'accounts', where, [...accountValues, ...values], order);
} else {
if (query.game) {
where.push('game = ?');
values.push(query.game);
}
if (query.server) {
where.push('server = ?');
values.push(query.server);
}
if (query.from) {
where.push('createdAt >= ?');
values.push(`${query.from}T00:00:00.000Z`);
}
if (query.to) {
where.push('createdAt <= ?');
values.push(`${query.to}T23:59:59.999Z`);
}
if (query.view === 'sessions') {
if (query.state) {
where.push('state = ?');
values.push(query.state);
}
sessionPage = page(
sessions,
'sessions',
where,
values,
'createdAt DESC, sessionId'
);
} else {
if (query.kind) {
where.push('kind = ?');
values.push(query.kind);
}
ledgerPage = page(ledger, 'ledger', where, values, 'createdAt DESC, id DESC');
}
}
return {
query,
checkedAt,
capacitySeconds: policy.capacitySeconds,
summary,
servers,
accounts: accountPage,
sessions: sessionPage,
ledger: ledgerPage
};
})();
}
+15 -2
View File
@@ -3,8 +3,9 @@
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js';
import Button from './primitives/Button.svelte';
let { account }: { account: ReturnType<typeof afkProfile> } = $props();
let { account, guid }: { account: ReturnType<typeof afkProfile>; guid?: string } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const change = (seconds: number) =>
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
@@ -12,7 +13,12 @@
</script>
<section aria-labelledby="afk-title">
<h2 id="afk-title">AFK reserve</h2>
<div class="heading">
<h2 id="afk-title">AFK reserve</h2>
{#if guid}<Button size="sm" href={`/afk?view=ledger&player=${encodeURIComponent(guid)}`}
>View AFK history</Button
>{/if}
</div>
<p>
<strong>{duration(account.freeSeconds)} free</strong> and
<strong>{duration(account.savedSeconds)} saved</strong>, shared across Black Ops I and II.
@@ -164,6 +170,13 @@
margin: 0 0 var(--space-2);
font-size: var(--text-lg);
}
.heading {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
}
dl {
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,97 @@
<script lang="ts">
import type { AfkAccount } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import RelativeTime from '../RelativeTime.svelte';
let {
entries,
capacitySeconds,
source
}: { entries: AfkAccount[]; capacitySeconds: number; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const historyHref = (guid: string, view: string) =>
`/afk?${new URLSearchParams({ view, player: guid })}`;
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK accounts" tabindex="0">
<table class="afk-table">
<caption class="sr-only">Shared player AFK accounts</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Available time</th><th scope="col"
>Saved capacity</th
><th scope="col">Reserved time</th><th scope="col">Last activity</th><th scope="col"
>History</th
></tr
></thead
>
<tbody>
{#each entries as account (account.guid)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(account.guid)}`,
source
)}>{account.name ?? account.guid}</a
><small class="afk-id">{account.guid}</small></td
>
<td class="afk-number"
><strong>{duration(account.availableSeconds)}</strong><small
>{duration(account.freeSeconds)} free · {duration(account.savedSeconds)} saved</small
>{#if account.freeRenewsAt}<small
>Free time next replenishes {$timeFormat.dateTime(
account.freeRenewsAt
)}</small
>{/if}</td
>
<td
><meter
min="0"
max={capacitySeconds}
value={Math.min(
account.savedSeconds + account.reservedSavedSeconds,
capacitySeconds
)}
aria-label={`${account.name ?? account.guid} saved time capacity`}
></meter><small
>{duration(account.savedSeconds + account.reservedSavedSeconds)} / {duration(
capacitySeconds
)}</small
>{#if account.reservedSavedSeconds > 0}<small
>Includes {duration(account.reservedSavedSeconds)} reserved</small
>{/if}</td
>
<td class="afk-number"
>{duration(account.reservedSeconds)}{#if account.unresolvedSessions}<small
class="afk-warning">Unresolved session</small
>{:else if account.reservedSeconds > 0}<small>Awaiting settlement</small
>{/if}</td
>
<td
>{#if account.lastActivity}<RelativeTime
value={account.lastActivity}
/>{:else}No activity{/if}</td
>
<td
><a href={historyHref(account.guid, 'sessions')}>Sessions</a><small
><a href={historyHref(account.guid, 'ledger')}>Time ledger</a></small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
meter {
width: 110px;
height: 12px;
accent-color: var(--accent-primary);
}
</style>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { AfkLedgerEntry } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import ServerIdentity from '../ServerIdentity.svelte';
let { entries, source }: { entries: AfkLedgerEntry[]; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const change = (seconds: number) =>
`${seconds > 0 ? '+' : seconds < 0 ? '−' : ''}${duration(Math.abs(seconds))}`;
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK time ledger" tabindex="0">
<table class="afk-table">
<caption class="sr-only">AFK grants, purchases, reservations and consumption</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Received / event</th><th scope="col"
>Free change</th
><th scope="col">Saved change</th><th scope="col">Balances after</th><th scope="col"
>Server / match</th
></tr
></thead
>
<tbody>
{#each entries as entry (entry.id)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(entry.guid)}`,
source
)}>{entry.name ?? entry.guid}</a
><small class="afk-id">{entry.guid}</small></td
>
<td
><time datetime={entry.createdAt}
>{$timeFormat.dateTime(entry.createdAt)}</time
><small>{entry.kind.replaceAll('_', ' ')}</small
>{#if entry.eventAt !== entry.createdAt}<small
>Reported {$timeFormat.dateTime(entry.eventAt)}</small
>{/if}<small class="afk-id">{entry.operationId}</small></td
>
<td class="afk-number">{change(entry.freeDelta)}</td><td class="afk-number"
>{change(entry.savedDelta)}</td
>
<td class="afk-number"
>{duration(entry.freeSeconds)} free<small
>{duration(entry.savedSeconds)} saved</small
></td
>
<td
><ServerIdentity name={entry.server} /><small
>{#if entry.matchAvailable && entry.matchId}<a
href={contextHref(`/matches/${entry.matchId}`, source)}
>Match #{entry.matchId}</a
>{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}No
match history{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,50 @@
<script lang="ts">
import { afkPageSize } from '$lib/afk.js';
import Button from '../primitives/Button.svelte';
let {
total,
offset,
disabled = false,
onchange
}: {
total: number;
offset: number;
disabled?: boolean;
onchange: (offset: number) => void;
} = $props();
</script>
<nav class="pagination" aria-label="AFK results pagination">
<span role="status"
>{total ? offset + 1 : 0}–{Math.min(offset + afkPageSize, total)} of {total.toLocaleString()}
results</span
>
{#if total > afkPageSize}<div>
<Button
size="sm"
disabled={disabled || offset === 0}
onclick={() => onchange(offset - afkPageSize)}>Previous</Button
><Button
size="sm"
disabled={disabled || offset + afkPageSize >= total}
onclick={() => onchange(offset + afkPageSize)}>Next</Button
>
</div>{/if}
</nav>
<style>
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-3);
padding-block: var(--space-3);
color: var(--text-secondary);
font-size: var(--text-sm);
}
.pagination div {
display: flex;
gap: var(--space-2);
}
</style>
@@ -0,0 +1,78 @@
<script lang="ts">
import type { AfkSession } from '$lib/afk.js';
import { contextHref } from '$lib/navigation-context.js';
import { formatDuration } from '$ui/duration.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import ServerIdentity from '../ServerIdentity.svelte';
let { entries, source }: { entries: AfkSession[]; source: URL } = $props();
const duration = (seconds: number) => formatDuration(seconds * 1000);
const label = (value: string) => value.replaceAll('_', ' ');
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll wide tables.) -->
<div class="afk-table-scroll" role="region" aria-label="AFK sessions" tabindex="0">
<table class="afk-table">
<caption class="sr-only">AFK session reservations and outcomes</caption>
<thead
><tr
><th scope="col">Player</th><th scope="col">Session</th><th scope="col">Time</th><th
scope="col">Outcome</th
><th scope="col">Server / match</th></tr
></thead
>
<tbody>
{#each entries as session (session.sessionId)}
<tr>
<td
><a
class="afk-player"
href={contextHref(
`/leaderboard/players/${encodeURIComponent(session.guid)}`,
source
)}>{session.name ?? session.guid}</a
><small class="afk-id">{session.guid}</small></td
>
<td
><time datetime={session.createdAt}
>{$timeFormat.dateTime(session.createdAt)}</time
><small
>{session.startedAt
? `Started ${$timeFormat.dateTime(session.startedAt)}`
: 'Start unconfirmed'}</small
><small class="afk-id">{session.sessionId}</small></td
>
<td class="afk-number"
><strong
>{session.consumedSeconds === null
? 'Unconfirmed duration'
: `${duration(session.consumedSeconds)} used`}</strong
><small>{duration(session.authorizedSeconds)} authorized</small
>{#if session.state === 'corrected' && session.chargedSeconds !== null}<small
>Corrected charge: {duration(session.chargedSeconds)}</small
>{/if}{#if session.consumedSeconds === null && session.confirmedSeconds > 0}<small
>Confirmed at least {duration(session.confirmedSeconds)}</small
>{/if}</td
>
<td
><span class:afk-warning={session.state === 'unresolved'}
>{label(session.state)}</span
>{#if session.endReason}<small>{label(session.endReason)}</small
>{/if}{#if session.endedAt}<small
>{$timeFormat.dateTime(session.endedAt)}</small
>{/if}</td
>
<td
><ServerIdentity name={session.server} /><small
>{#if session.matchAvailable && session.matchId}<a
href={contextHref(`/matches/${session.matchId}`, source)}
>Match #{session.matchId}</a
>{:else if session.matchId}Match #{session.matchId} · History expired{:else}No
match history{/if}</small
></td
>
</tr>
{/each}
</tbody>
</table>
</div>
+53
View File
@@ -0,0 +1,53 @@
.afk-table-scroll {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
}
.afk-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
.afk-table th {
color: var(--text-tertiary);
background: var(--surface-sunken);
font-weight: var(--weight-medium);
white-space: nowrap;
}
.afk-table th,
.afk-table td {
padding: var(--space-4);
vertical-align: top;
}
.afk-table tbody tr + tr {
border-top: 1px solid var(--border-subtle);
}
.afk-table td {
min-width: 130px;
}
.afk-table small {
display: block;
margin-top: var(--space-1);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.afk-id {
font-family: var(--font-mono);
overflow-wrap: anywhere;
}
.afk-number {
font-variant-numeric: tabular-nums;
}
.afk-table a {
color: var(--accent-primary-hover);
}
.afk-player {
font-weight: var(--weight-semibold);
overflow-wrap: anywhere;
}
.afk-table .afk-warning {
color: var(--state-warning);
}
+13
View File
@@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { readAfkQuery } from '$lib/afk.js';
import { afkOverview } from '$lib/server/fleet/afk-overview.js';
export function load({ url }: { url: URL }) {
let query;
try {
query = readAfkQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid AFK filters.');
}
return { report: afkOverview(query) };
}
+484
View File
@@ -0,0 +1,484 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
import { afkLedgerKinds, type AfkOverview, type AfkQuery } from '$lib/afk.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.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 EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import AfkAccounts from '$ui/components/afk/AfkAccounts.svelte';
import AfkSessions from '$ui/components/afk/AfkSessions.svelte';
import AfkLedger from '$ui/components/afk/AfkLedger.svelte';
import AfkPagination from '$ui/components/afk/AfkPagination.svelte';
import '$ui/styles/afk.css';
let { data }: { data: import('./$types').PageData } = $props();
const number = new Intl.NumberFormat('en-US');
const duration = (seconds: number) => formatDuration(seconds * 1000);
const label = (value: string) => value.replaceAll('_', ' ');
let snapshot = $state<AfkOverview | null>(null);
const report = $derived(snapshot ?? data.report);
let filters = $state<AfkQuery>(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 = [
{ id: 'accounts', label: 'Accounts' },
{ id: 'sessions', label: 'Sessions' },
{ id: 'ledger', label: 'Time ledger' }
];
const serverOptions = $derived([
{ value: '', label: 'All servers' },
...[...new Set([...report.servers, ...(filters.server ? [filters.server] : [])])].map(
(server) => ({ value: server, label: serverTitle(server) })
)
]);
const clearedFilters = {
q: null,
balance: null,
sort: null,
game: null,
server: null,
state: null,
kind: null,
from: null,
to: null,
offset: null
};
$effect(() => {
const initial = data.report;
untrack(() => {
cancelRefresh();
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/afk${$page.url.search}`, { signal: request.signal });
if (!response.ok) throw new Error('AFK refresh failed.');
const next: AfkOverview = await response.json();
if (current !== generation || request.signal.aborted) return;
snapshot = next;
updatedAt = Date.now();
refreshError = '';
} catch {
if (current === generation) refreshError = 'AFK updates paused.';
return false;
} finally {
clearTimeout(timeout);
signal?.removeEventListener('abort', abort);
if (current === generation) refreshing = false;
}
}
onMount(() => {
updatedAt = Date.now();
const stop = pollVisible(refresh, 15_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(`/afk?${params}`, { keepFocus: true, noScroll: true });
} catch {
navigationError = 'Could not load this AFK view. Please try again.';
}
}
function changeView(view: string) {
void navigate({ ...clearedFilters, q: report.query.q, view });
}
function applyFilters(event: SubmitEvent) {
event.preventDefault();
void navigate({
q: filters.q,
balance: filters.balance,
sort: filters.sort,
game: filters.game,
server: filters.server,
state: filters.state,
kind: filters.kind,
from: filters.from,
to: filters.to,
offset: null
});
}
</script>
<svelte:head><title>AFK · Electric Cherry</title></svelte:head>
<div class="afk-page">
<SectionHeader
title="AFK"
eyebrow="Players"
subtitle="Shared AFK time across Black Ops I & II."
>
{#snippet actions()}
<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 15 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>AFK accounts</dt>
<dd>{number.format(report.summary.accounts)}</dd>
</div>
<div>
<dt>Available saved time</dt>
<dd>{duration(report.summary.savedSeconds)}</dd>
</div>
<div>
<dt>Reserved time</dt>
<dd>{duration(report.summary.reservedSeconds)}</dd>
</div>
<div>
<dt>Unresolved sessions</dt>
<dd>
<button
class="summary-link"
disabled={busy}
onclick={() =>
void navigate({
...clearedFilters,
player: null,
view: 'sessions',
state: 'unresolved'
})}>{number.format(report.summary.unresolvedSessions)}</button
>
</dd>
</div>
</dl>
<p class="hint">
Totals cover all accounts, regardless of filters. Reserved time is held until settlement and
is excluded from available balances.
</p>
{#if navigationError}<p class="ui-error" role="alert">{navigationError}</p>{/if}
<Tabs
{tabs}
value={report.query.view}
onchange={changeView}
ariaLabel="AFK views"
panelId="afk-results"
idPrefix="afk-tab"
/>
<div
id="afk-results"
role="tabpanel"
aria-labelledby={`afk-tab-${report.query.view}`}
aria-busy={busy}
class="results"
>
{#if report.query.player}
<div class="player-filter">
<span>Player GUID: <strong>{report.query.player}</strong></span><Button
size="sm"
variant="ghost"
disabled={busy}
onclick={() => void navigate({ player: null, offset: null })}
>All players</Button
>
</div>
{/if}
<form class="filters" method="GET" action="/afk" onsubmit={applyFilters}>
<input type="hidden" name="view" value={report.query.view} />
{#if report.query.player}<input
type="hidden"
name="player"
value={report.query.player}
/>{/if}
<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: 'saved', label: 'Saved time available' },
{ value: 'reserved', label: 'Reserved time' },
{ value: 'unresolved', label: 'Unresolved sessions' }
]}
/></Field
>
<Field label="Sort by"
><Select
name="sort"
bind:value={filters.sort}
options={[
{ value: 'saved', label: 'Most saved time' },
{ value: 'available', label: 'Most available time' },
{ 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
>
{#if report.query.view === 'sessions'}
<Field label="Session state"
><Select
name="state"
bind:value={filters.state}
options={[
{ value: '', label: 'All states' },
{ value: 'reserved', label: 'Reserved' },
{ value: 'started', label: 'Started' },
{ value: 'settled', label: 'Settled' },
{ value: 'unresolved', label: 'Unresolved' },
{ value: 'corrected', label: 'Corrected' }
]}
/></Field
>
{:else}
<Field label="Event"
><Select
name="kind"
bind:value={filters.kind}
options={[
{ value: '', label: 'All events' },
...afkLedgerKinds.map((kind) => ({
value: kind,
label: label(kind)
}))
]}
/></Field
>
{/if}
<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({ ...clearedFilters, player: null })}>Reset</Button
>
</div>
</form>
{#if report.query.view === 'accounts'}
<p class="hint">
Free time replenishes on a rolling 24-hour basis. Saved capacity is {duration(
report.capacitySeconds
)} per player, including saved time reserved for sessions.
</p>
{#if report.accounts.entries.length}<AfkAccounts
entries={report.accounts.entries}
capacitySeconds={report.capacitySeconds}
source={$page.url}
/>{:else}<EmptyState
title="No AFK accounts found"
description="Try another player name or GUID, or reset the filters. Accounts appear after their first AFK service request."
/>{/if}
{:else if report.query.view === 'sessions'}
<p class="hint">
Dates filter when reservations were created. Unresolved sessions keep their time
reserved until a confirmed stop or an administrator correction. Corrected charges
are separate from confirmed usage.
</p>
{#if report.sessions.entries.length}<AfkSessions
entries={report.sessions.entries}
source={$page.url}
/>{:else}<EmptyState
title="No AFK sessions found"
description="No sessions match these filters."
/>{/if}
{:else}
<p class="hint">
Dates filter when events were received. Balances after each event are historical
snapshots; current balances appear in Accounts.
</p>
{#if report.ledger.entries.length}<AfkLedger
entries={report.ledger.entries}
source={$page.url}
/>{:else}<EmptyState
title="No time ledger entries found"
description="No AFK grants, purchases or session activity match these filters."
/>{/if}
{/if}
<AfkPagination
total={results.total}
offset={results.offset}
disabled={busy}
onchange={(offset) => void navigate({ offset })}
/>
</div>
</div>
<style>
.afk-page,
.results {
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;
overflow-wrap: anywhere;
}
.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);
}
.player-filter {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
font-size: var(--text-sm);
overflow-wrap: anywhere;
}
.player-filter strong {
font-family: var(--font-mono);
}
@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>
+13
View File
@@ -0,0 +1,13 @@
import { error, json } from '@sveltejs/kit';
import { readAfkQuery } from '$lib/afk.js';
import { afkOverview } from '$lib/server/fleet/afk-overview.js';
export function GET({ url }: { url: URL }) {
let query;
try {
query = readAfkQuery(url.searchParams);
} catch (cause) {
error(400, cause instanceof Error ? cause.message : 'Invalid AFK filters.');
}
return json(afkOverview(query), { headers: { 'Cache-Control': 'no-store' } });
}
@@ -223,7 +223,7 @@
</div>
{:else}<p>No bank transfers yet.</p>{/if}
</section>
<AfkReserveCard account={data.afk} />
<AfkReserveCard account={data.afk} guid={profile.guid} />
<nav class="jump-links" aria-label="Player sections">
<a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title"
>Matches</a