This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user