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