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