From 59f88ac33d78714196365afaafaabd226704c8c6 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 17 Oct 2026 17:52:03 -0400 Subject: [PATCH] Add AFK account and session overview (Refs #151) --- docs/guides/afk.md | 4 +- src/lib/afk.ts | 143 ++++++ src/lib/navigation-context.ts | 3 +- src/lib/navigation.ts | 1 + src/lib/server/fleet/afk-overview.ts | 197 +++++++ src/lib/ui/components/AfkReserveCard.svelte | 17 +- src/lib/ui/components/afk/AfkAccounts.svelte | 97 ++++ src/lib/ui/components/afk/AfkLedger.svelte | 68 +++ .../ui/components/afk/AfkPagination.svelte | 50 ++ src/lib/ui/components/afk/AfkSessions.svelte | 78 +++ src/lib/ui/styles/afk.css | 53 ++ src/routes/afk/+page.server.ts | 13 + src/routes/afk/+page.svelte | 484 ++++++++++++++++++ src/routes/api/afk/+server.ts | 13 + .../leaderboard/players/[guid]/+page.svelte | 2 +- tests/client/afk-page.test.ts | 320 ++++++++++++ tests/unit/afk-overview.test.ts | 451 ++++++++++++++++ tests/unit/auth-access.test.ts | 17 + tests/unit/navigation-context.test.ts | 9 + tests/unit/navigation.test.ts | 9 + tests/unit/ui-afk-profile.test.ts | 4 +- tests/unit/ui-navigation.test.ts | 8 + 22 files changed, 2035 insertions(+), 6 deletions(-) create mode 100644 src/lib/afk.ts create mode 100644 src/lib/server/fleet/afk-overview.ts create mode 100644 src/lib/ui/components/afk/AfkAccounts.svelte create mode 100644 src/lib/ui/components/afk/AfkLedger.svelte create mode 100644 src/lib/ui/components/afk/AfkPagination.svelte create mode 100644 src/lib/ui/components/afk/AfkSessions.svelte create mode 100644 src/lib/ui/styles/afk.css create mode 100644 src/routes/afk/+page.server.ts create mode 100644 src/routes/afk/+page.svelte create mode 100644 src/routes/api/afk/+server.ts create mode 100644 tests/client/afk-page.test.ts create mode 100644 tests/unit/afk-overview.test.ts diff --git a/docs/guides/afk.md b/docs/guides/afk.md index 826a859..98ac768 100644 --- a/docs/guides/afk.md +++ b/docs/guides/afk.md @@ -99,7 +99,9 @@ An administrator can reconcile an explicitly stopped `unresolved` session with ` The selected charge must be at least the confirmed checkpoint and no more than authorization. Correction cannot release a live reserved/started session, and does not convert an uncertain duration into an exact one: `state` becomes `corrected`, `consumedSeconds` remains null, `chargedSeconds` records the accounting decision, and `confirmedSeconds` retains the evidence lower bound. The audit ledger records the actor, reason, charge, and release. The correction ID is idempotent and rejects changed parameters. A true confirmed stop report can instead settle an unresolved session with its verified exact duration. -The existing player profile access controls protect balances and recent time-ledger entries alongside bank information. Reports distinguish cumulative verified active seconds, confirmed AFK duration, unresolved sessions, expiration count, manual returns, purchases, and corrected charges. Session starts, end reasons, operation/session IDs, server, match when retained, reported event time, receipt time, resulting balances, free grants, rewards, point-linked purchases, reservations, consumption, releases, and corrections survive match retention and server deletion. +Open **Players → AFK** (`/afk`) to browse all AFK accounts, sessions, and time-ledger entries. The accounts view supports player-name and exact-GUID searches and links each account to its player profile. Session history includes unresolved reservations; session and ledger history can be filtered by game, server, and date. Filters and pagination remain in the URL when opening a player or retained match, so the back link restores the same overview. Refresh reads current balances without issuing grants, releasing reservations, or otherwise changing accounts. This reporting page has no purchase or correction controls. + +The AFK overview and player profiles use the same signed-in viewer access as Bank. Player profiles also retain balances and recent time-ledger entries alongside bank information. Reports distinguish cumulative verified active seconds, confirmed AFK duration, unresolved sessions, expiration count, manual returns, purchases, and corrected charges. Session starts, end reasons, operation/session IDs, server, match when retained, reported event time, receipt time, resulting balances, free grants, rewards, point-linked purchases, reservations, consumption, releases, and corrections survive match retention and server deletion. ## Coordinated rollout and validation diff --git a/src/lib/afk.ts b/src/lib/afk.ts new file mode 100644 index 0000000..d3a1ce0 --- /dev/null +++ b/src/lib/afk.ts @@ -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(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 { + 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; + sessions: AfkPage; + ledger: AfkPage; +} diff --git a/src/lib/navigation-context.ts b/src/lib/navigation-context.ts index f2d7a8e..51114a8 100644 --- a/src/lib/navigation-context.ts +++ b/src/lib/navigation-context.ts @@ -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' }; diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts index 0e4d46e..0f08cfc 100644 --- a/src/lib/navigation.ts +++ b/src/lib/navigation.ts @@ -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' } ] diff --git a/src/lib/server/fleet/afk-overview.ts b/src/lib/server/fleet/afk-overview.ts new file mode 100644 index 0000000..1667f88 --- /dev/null +++ b/src/lib/server/fleet/afk-overview.ts @@ -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 = (): AfkPage => ({ 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( + cte: string, + table: string, + where: string[], + values: (string | number)[], + order: string + ): AfkPage { + 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(); + let sessionPage = emptyPage(); + let ledgerPage = emptyPage(); + 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 + }; + })(); +} diff --git a/src/lib/ui/components/AfkReserveCard.svelte b/src/lib/ui/components/AfkReserveCard.svelte index bce1c5b..57d3b09 100644 --- a/src/lib/ui/components/AfkReserveCard.svelte +++ b/src/lib/ui/components/AfkReserveCard.svelte @@ -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 } = $props(); + let { account, guid }: { account: ReturnType; 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 @@
-

AFK reserve

+
+

AFK reserve

+ {#if guid}{/if} +

{duration(account.freeSeconds)} free and {duration(account.savedSeconds)} saved, 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; diff --git a/src/lib/ui/components/afk/AfkAccounts.svelte b/src/lib/ui/components/afk/AfkAccounts.svelte new file mode 100644 index 0000000..9235a60 --- /dev/null +++ b/src/lib/ui/components/afk/AfkAccounts.svelte @@ -0,0 +1,97 @@ + + + +

+ + + + + {#each entries as account (account.guid)} + + + + + + + + + {/each} + +
Shared player AFK accounts
PlayerAvailable timeSaved capacityReserved timeLast activityHistory
{account.name ?? account.guid}{account.guid}{duration(account.availableSeconds)}{duration(account.freeSeconds)} free · {duration(account.savedSeconds)} saved{#if account.freeRenewsAt}Free time next replenishes {$timeFormat.dateTime( + account.freeRenewsAt + )}{/if}{duration(account.savedSeconds + account.reservedSavedSeconds)} / {duration( + capacitySeconds + )}{#if account.reservedSavedSeconds > 0}Includes {duration(account.reservedSavedSeconds)} reserved{/if}{duration(account.reservedSeconds)}{#if account.unresolvedSessions}Unresolved session{:else if account.reservedSeconds > 0}Awaiting settlement{/if}{#if account.lastActivity}{:else}No activity{/if}SessionsTime ledger
+
+ + diff --git a/src/lib/ui/components/afk/AfkLedger.svelte b/src/lib/ui/components/afk/AfkLedger.svelte new file mode 100644 index 0000000..05eeeab --- /dev/null +++ b/src/lib/ui/components/afk/AfkLedger.svelte @@ -0,0 +1,68 @@ + + + +
+ + + + + {#each entries as entry (entry.id)} + + + + + + + + {/each} + +
AFK grants, purchases, reservations and consumption
PlayerReceived / eventFree changeSaved changeBalances afterServer / match
{entry.name ?? entry.guid}{entry.guid}{entry.kind.replaceAll('_', ' ')}{#if entry.eventAt !== entry.createdAt}Reported {$timeFormat.dateTime(entry.eventAt)}{/if}{entry.operationId}{change(entry.freeDelta)}{change(entry.savedDelta)}{duration(entry.freeSeconds)} free{duration(entry.savedSeconds)} saved{#if entry.matchAvailable && entry.matchId}Match #{entry.matchId}{:else if entry.matchId}Match #{entry.matchId} · History expired{:else}No + match history{/if}
+
diff --git a/src/lib/ui/components/afk/AfkPagination.svelte b/src/lib/ui/components/afk/AfkPagination.svelte new file mode 100644 index 0000000..cb31de6 --- /dev/null +++ b/src/lib/ui/components/afk/AfkPagination.svelte @@ -0,0 +1,50 @@ + + + + + diff --git a/src/lib/ui/components/afk/AfkSessions.svelte b/src/lib/ui/components/afk/AfkSessions.svelte new file mode 100644 index 0000000..3c9be2d --- /dev/null +++ b/src/lib/ui/components/afk/AfkSessions.svelte @@ -0,0 +1,78 @@ + + + +
+ + + + + {#each entries as session (session.sessionId)} + + + + + + + + {/each} + +
AFK session reservations and outcomes
PlayerSessionTimeOutcomeServer / match
{session.name ?? session.guid}{session.guid}{session.startedAt + ? `Started ${$timeFormat.dateTime(session.startedAt)}` + : 'Start unconfirmed'}{session.sessionId}{session.consumedSeconds === null + ? 'Unconfirmed duration' + : `${duration(session.consumedSeconds)} used`}{duration(session.authorizedSeconds)} authorized{#if session.state === 'corrected' && session.chargedSeconds !== null}Corrected charge: {duration(session.chargedSeconds)}{/if}{#if session.consumedSeconds === null && session.confirmedSeconds > 0}Confirmed at least {duration(session.confirmedSeconds)}{/if}{label(session.state)}{#if session.endReason}{label(session.endReason)}{/if}{#if session.endedAt}{$timeFormat.dateTime(session.endedAt)}{/if}{#if session.matchAvailable && session.matchId}Match #{session.matchId}{:else if session.matchId}Match #{session.matchId} · History expired{:else}No + match history{/if}
+
diff --git a/src/lib/ui/styles/afk.css b/src/lib/ui/styles/afk.css new file mode 100644 index 0000000..784cc5c --- /dev/null +++ b/src/lib/ui/styles/afk.css @@ -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); +} diff --git a/src/routes/afk/+page.server.ts b/src/routes/afk/+page.server.ts new file mode 100644 index 0000000..29749e7 --- /dev/null +++ b/src/routes/afk/+page.server.ts @@ -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) }; +} diff --git a/src/routes/afk/+page.svelte b/src/routes/afk/+page.svelte new file mode 100644 index 0000000..5411e2e --- /dev/null +++ b/src/routes/afk/+page.svelte @@ -0,0 +1,484 @@ + + +AFK · Electric Cherry + +
+ + {#snippet actions()} + void refresh()} + /> + + {/snippet} + + +
+
+
AFK accounts
+
{number.format(report.summary.accounts)}
+
+
+
Available saved time
+
{duration(report.summary.savedSeconds)}
+
+
+
Reserved time
+
{duration(report.summary.reservedSeconds)}
+
+
+
Unresolved sessions
+
+ +
+
+
+

+ Totals cover all accounts, regardless of filters. Reserved time is held until settlement and + is excluded from available balances. +

+ {#if navigationError}{/if} + + +
+ {#if report.query.player} +
+ Player GUID: {report.query.player} +
+ {/if} +
+ + {#if report.query.player}{/if} + + {#if report.query.view === 'accounts'} + + {:else} + + {#if report.query.view === 'sessions'} + ({ + value: kind, + label: label(kind) + })) + ]} + /> + {/if} + + + {/if} +
+ +
+
+ + {#if report.query.view === 'accounts'} +

+ Free time replenishes on a rolling 24-hour basis. Saved capacity is {duration( + report.capacitySeconds + )} per player, including saved time reserved for sessions. +

+ {#if report.accounts.entries.length}{:else}{/if} + {:else if report.query.view === 'sessions'} +

+ 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. +

+ {#if report.sessions.entries.length}{:else}{/if} + {:else} +

+ Dates filter when events were received. Balances after each event are historical + snapshots; current balances appear in Accounts. +

+ {#if report.ledger.entries.length}{:else}{/if} + {/if} + void navigate({ offset })} + /> +
+
+ + diff --git a/src/routes/api/afk/+server.ts b/src/routes/api/afk/+server.ts new file mode 100644 index 0000000..d8bccfb --- /dev/null +++ b/src/routes/api/afk/+server.ts @@ -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' } }); +} diff --git a/src/routes/leaderboard/players/[guid]/+page.svelte b/src/routes/leaderboard/players/[guid]/+page.svelte index 45c8615..b22bd18 100644 --- a/src/routes/leaderboard/players/[guid]/+page.svelte +++ b/src/routes/leaderboard/players/[guid]/+page.svelte @@ -223,7 +223,7 @@ {:else}

No bank transfers yet.

{/if}
- +