207 lines
7.8 KiB
Svelte
207 lines
7.8 KiB
Svelte
<script lang="ts">
|
||
import type { afkProfile } from '$lib/server/fleet/afk.js';
|
||
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, 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))}`;
|
||
const label = (value: string) => value.replaceAll('_', ' ');
|
||
</script>
|
||
|
||
<section aria-labelledby="afk-title">
|
||
<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.
|
||
Saved capacity: {duration(account.capacitySeconds)}.
|
||
</p>
|
||
{#if account.freeRenewsAt}
|
||
<p>
|
||
Free time next replenishes {$timeFormat.dateTime(account.freeRenewsAt)}. Unused free
|
||
time does not accumulate.
|
||
</p>
|
||
{/if}
|
||
{#if account.reservedSeconds > 0}
|
||
<p>
|
||
{duration(account.reservedSeconds)} is reserved for a session and unavailable to other servers.
|
||
</p>
|
||
{/if}
|
||
<dl>
|
||
<div>
|
||
<dt>Recorded active play</dt>
|
||
<dd>{duration(account.activeSeconds)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Confirmed AFK time</dt>
|
||
<dd>{duration(account.confirmedAfkSeconds)}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Manual returns</dt>
|
||
<dd>{account.manualReturns}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Expirations</dt>
|
||
<dd>{account.expiredSessions}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Purchases</dt>
|
||
<dd>{account.purchaseCount} ({duration(account.purchasedSeconds)})</dd>
|
||
</div>
|
||
</dl>
|
||
{#if account.unresolvedSessions > 0}
|
||
<p>
|
||
{account.unresolvedSessions} session(s) have an unconfirmed duration. Their time is excluded
|
||
from confirmed AFK usage.
|
||
</p>
|
||
{/if}
|
||
{#if account.entries.length}
|
||
<details>
|
||
<summary>Recent time ledger</summary>
|
||
<div class="table-scroll">
|
||
<table>
|
||
<caption>Recent AFK grants, reservations and consumption</caption>
|
||
<thead
|
||
><tr
|
||
><th scope="col">Time / 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 account.entries as entry (entry.id)}
|
||
<tr>
|
||
<td
|
||
>{$timeFormat.dateTime(entry.eventAt)}<small
|
||
>{label(entry.kind)}</small
|
||
><small>{entry.operationId}</small></td
|
||
>
|
||
<td>{change(entry.freeDelta)}</td>
|
||
<td>{change(entry.savedDelta)}</td>
|
||
<td
|
||
>{duration(entry.freeSeconds)} free<small
|
||
>{duration(entry.savedSeconds)} saved</small
|
||
></td
|
||
>
|
||
<td
|
||
>{serverTitle(entry.server)}<small
|
||
>{#if entry.matchAvailable}<a
|
||
href={`/matches/${entry.matchId}`}
|
||
>Match #{entry.matchId}</a
|
||
>{:else if entry.matchId}Match #{entry.matchId} (history expired){:else}—{/if}</small
|
||
></td
|
||
>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
{:else}
|
||
<p>No AFK activity recorded yet.</p>
|
||
{/if}
|
||
{#if account.sessions.length}
|
||
<details>
|
||
<summary>Recent AFK sessions</summary>
|
||
<div class="table-scroll">
|
||
<table>
|
||
<caption>Accepted AFK transitions</caption>
|
||
<thead
|
||
><tr
|
||
><th scope="col">Started</th><th scope="col">Server</th><th scope="col"
|
||
>Duration</th
|
||
><th scope="col">Outcome</th></tr
|
||
></thead
|
||
>
|
||
<tbody>
|
||
{#each account.sessions as session (session.sessionId)}
|
||
<tr>
|
||
<td
|
||
>{session.startedAt
|
||
? $timeFormat.dateTime(session.startedAt)
|
||
: 'Start unconfirmed'}</td
|
||
>
|
||
<td>{serverTitle(session.server)}</td>
|
||
<td
|
||
>{session.consumedSeconds === null
|
||
? 'Unconfirmed'
|
||
: duration(session.consumedSeconds)}
|
||
{#if session.state === 'corrected' && session.chargedSeconds !== null}
|
||
<small
|
||
>Corrected charge: {duration(
|
||
session.chargedSeconds
|
||
)}</small
|
||
>
|
||
{/if}</td
|
||
>
|
||
<td
|
||
>{label(
|
||
session.endReason ?? session.state
|
||
)}{#if session.endedAt}<small
|
||
>{$timeFormat.dateTime(session.endedAt)}</small
|
||
>{/if}</td
|
||
>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</details>
|
||
{/if}
|
||
</section>
|
||
|
||
<style>
|
||
section {
|
||
padding: var(--space-5);
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface);
|
||
}
|
||
h2 {
|
||
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;
|
||
gap: var(--space-5);
|
||
}
|
||
dt,
|
||
small {
|
||
color: var(--text-muted);
|
||
font-size: var(--text-sm);
|
||
}
|
||
dd {
|
||
margin: var(--space-1) 0 0;
|
||
}
|
||
small {
|
||
display: block;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
details {
|
||
margin-top: var(--space-3);
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
}
|
||
a {
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
</style>
|