This commit is contained in:
+143
@@ -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,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'
|
||||
};
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
@@ -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
|
||||
};
|
||||
})();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user