178 lines
15 KiB
Svelte
178 lines
15 KiB
Svelte
<script lang="ts">
|
||
import { untrack } from 'svelte';
|
||
import { page } from '$app/stores';
|
||
import { contextHref } from '$lib/navigation-context.js';
|
||
import { goto } from '$app/navigation';
|
||
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||
import type { MatchHistoryResponse } from '$lib/match-history.js';
|
||
import { matchApiParams, matchHistoryHref, matchPageSize, readMatchQuery, type MatchHistoryQuery, type MatchSort } from '$lib/match-history-query.js';
|
||
import { matchInstanceLabel, matchStartedLabel } from '$ui/match-history.js';
|
||
import { pollVisible } from '$ui/poll.js';
|
||
import { formatDuration } from '$ui/duration.js';
|
||
import Button from './primitives/Button.svelte';
|
||
import EmptyState from './primitives/EmptyState.svelte';
|
||
|
||
let { server, initial }: { server?: string; initial?: MatchHistoryResponse } = $props();
|
||
let data = $state<MatchHistoryResponse | undefined>(untrack(() => initial));
|
||
let loading = $state(!untrack(() => initial));
|
||
let error = $state('');
|
||
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
|
||
let retry = $state(0);
|
||
let tableRegion = $state<HTMLDivElement>();
|
||
let pendingUrl: URL | undefined;
|
||
let navigationId = 0;
|
||
const filters = $derived(readMatchQuery(matchApiParams($page.url)));
|
||
const apiUrl = $derived.by(() => {
|
||
const params = matchApiParams($page.url);
|
||
params.set('limit', String(matchPageSize));
|
||
const endpoint = server === undefined ? '/api/matches' : `/api/servers/${encodeURIComponent(server)}/matches`;
|
||
return `${endpoint}?${params}`;
|
||
});
|
||
let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
|
||
const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status));
|
||
const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }];
|
||
|
||
$effect(() => { search = filters.q; });
|
||
$effect(() => {
|
||
const report = initial;
|
||
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; });
|
||
});
|
||
$effect(() => {
|
||
const url = apiUrl;
|
||
retry;
|
||
untrack(() => { if (loadedUrl !== url) loading = true; error = ''; });
|
||
return pollVisible(async signal => {
|
||
try {
|
||
const response = await fetch(url, { signal, cache: 'no-store' });
|
||
if (!response.ok) throw new Error('Could not load match history.');
|
||
const body: MatchHistoryResponse = await response.json();
|
||
if (signal.aborted || url !== apiUrl) return;
|
||
// Do not move keyboard users to another match while they are following a link.
|
||
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body;
|
||
loadedUrl = url; loading = false; error = '';
|
||
} catch (cause) {
|
||
if (signal.aborted || url !== apiUrl) return;
|
||
error = cause instanceof Error ? cause.message : 'Could not load match history.';
|
||
loading = false;
|
||
return false;
|
||
}
|
||
}, 10000);
|
||
});
|
||
function update(changes: Partial<Record<keyof MatchHistoryQuery, string>>) {
|
||
const href = matchHistoryHref(pendingUrl ?? $page.url, changes);
|
||
pendingUrl = new URL(href, $page.url);
|
||
const id = ++navigationId;
|
||
void goto(href, { noScroll: true, keepFocus: true })
|
||
.catch(() => { error = 'Could not update the match filters. Please try again.'; })
|
||
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
|
||
}
|
||
function sort(key: MatchSort) { update({ sort: key, direction: filters.sort === key && filters.direction === 'desc' ? 'asc' : 'desc' }); }
|
||
function reset() { update({ game: '', map: '', q: '', days: '', status: '' }); }
|
||
</script>
|
||
|
||
<section class="history" aria-label="Match history">
|
||
<div class="filters">
|
||
{#if server === undefined}
|
||
<label>Game<select value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||
{/if}
|
||
<label>Map<select value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
|
||
<option value="">All maps</option>
|
||
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
|
||
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game.toUpperCase()}` : ''}</option>{/each}
|
||
</select></label>
|
||
{#if server === undefined}
|
||
<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); }}>
|
||
<label for="match-search">Instance<input id="match-search" type="search" bind:value={search} maxlength="100" placeholder="Find an instance…" aria-describedby="match-search-help" /></label>
|
||
<span id="match-search-help" class="sr-only">Press Enter to search by instance name.</span>
|
||
</form>
|
||
{/if}
|
||
<label>Date range<select value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
|
||
<details class="more-filters"><summary>More filters{filters.status ? ' (1)' : ''}</summary>
|
||
<label>Status<select value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
|
||
</details>
|
||
</div>
|
||
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Matches started in the last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}</div>
|
||
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
|
||
{#if loading}<EmptyState title="Loading matches…" />
|
||
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." />
|
||
{:else if data?.matches.length}
|
||
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<div class="table-scroll" bind:this={tableRegion} tabindex="0" role="region" aria-label="Recorded matches">
|
||
<table>
|
||
<caption class="sr-only">Matches sorted by {sorts.find(item => item.key === filters.sort)?.label.toLowerCase()}, {filters.direction === 'desc' ? 'highest or newest' : 'lowest or oldest'} first</caption>
|
||
<thead><tr>
|
||
<th scope="col" aria-sort={filters.sort === 'started' ? filters.direction === 'desc' ? 'descending' : 'ascending' : 'none'}><button onclick={() => sort('started')}>Started{filters.sort === 'started' ? filters.direction === 'desc' ? ' ↓' : ' ↑' : ''}</button></th>
|
||
<th scope="col">Match</th><th scope="col">Status</th>
|
||
{#each sorts.filter(item => item.key !== 'started') as item}
|
||
<th scope="col" class="numeric" aria-sort={filters.sort === item.key ? filters.direction === 'desc' ? 'descending' : 'ascending' : 'none'}><button onclick={() => sort(item.key)} title={item.key === 'players' ? 'Unique players observed throughout the match' : item.key === 'round' ? 'Final round, or highest observed round for unfinished matches' : 'Recorded duration; unknown durations sort last'} aria-describedby={item.key === 'players' ? 'match-player-help' : undefined}>{item.label}{filters.sort === item.key ? filters.direction === 'desc' ? ' ↓' : ' ↑' : ''}</button></th>
|
||
{/each}
|
||
</tr></thead>
|
||
<tbody>{#each data.matches as match (match.id)}
|
||
<tr>
|
||
<td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{matchStartedLabel(match.startedAt, $timeZone)}</time></td>
|
||
<td class="match">
|
||
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map}</a>
|
||
{:else}<span class="match-name">{match.map}</span>{/if}
|
||
{#if server === undefined}<div class="instance">
|
||
{#if !match.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(match.server)}?tab=matches`, $page.url)} title={match.server}>{matchInstanceLabel(match)}</a>
|
||
{:else}<span title={match.server}>{matchInstanceLabel(match)}</span><span> · Historical instance</span>{/if}
|
||
</div>{/if}
|
||
</td>
|
||
<td><span class="status" class:active={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td>
|
||
<td class="numeric">{match.uniquePlayers}</td>
|
||
<td class="numeric"><span title={match.durationMs === null ? 'Duration was not recorded or the match has not completed.' : undefined}>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</span></td>
|
||
<td class="numeric">{match.status === 'completed' ? match.finalRound ?? '—' : match.observedRound ?? '—'}{#if match.status !== 'completed'}<small>Highest observed</small>{/if}</td>
|
||
</tr>
|
||
{/each}</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="pagination"><span>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit + data.matches.length} of {data.total} matches</span>
|
||
{#if data.total > data.limit}<div><Button size="sm" disabled={data.page <= 1} href={matchHistoryHref($page.url, { page: String(data.page - 1) })}>Previous</Button><Button size="sm" disabled={data.page * data.limit >= data.total} href={matchHistoryHref($page.url, { page: String(data.page + 1) })}>Next</Button></div>{/if}
|
||
</div>
|
||
{:else if !error}<EmptyState title={hasFilters ? 'No matches found' : 'No matches recorded yet'} description={hasFilters ? 'Try another game, map, instance, or date range.' : 'Matches appear when instances report gameplay.'}>
|
||
{#snippet actions()}{#if hasFilters}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
|
||
</EmptyState>{/if}
|
||
<span id="match-player-help" class="sr-only">Players counts unique players observed throughout the match.</span>
|
||
<details class="about"><summary>About match history</summary><p>Players counts unique players across all reports. A dash means the value is unavailable, not zero. Duration requires a recorded start and completed ending. Round shows the final round for completed matches and the highest observed round for unfinished matches. Open a completed match for exact timestamps and starting and ending player counts.</p></details>
|
||
</section>
|
||
|
||
<style>
|
||
.history { display: grid; gap: var(--space-4); min-width: 0; }
|
||
.filters { display: flex; align-items: flex-start; flex-wrap: wrap; gap: var(--space-3); }
|
||
label, form { flex: 1; min-width: 145px; }
|
||
label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); }
|
||
input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||
.more-filters { padding-top: 25px; font-size: var(--text-sm); }
|
||
.more-filters label { margin-top: 10px; }
|
||
summary { cursor: pointer; color: var(--text-secondary); }
|
||
.context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
|
||
.pagination > div { display: flex; gap: var(--space-2); }
|
||
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
|
||
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); }
|
||
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||
th { color: var(--text-secondary); font-size: var(--text-xs); background: var(--surface-sunken); }
|
||
th button, .text-button { border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; padding: 4px 0; }
|
||
.text-button { color: var(--accent-primary-hover); }
|
||
td { font-variant-numeric: tabular-nums; }
|
||
tr:last-child td { border-bottom: 0; }
|
||
tbody tr:hover, tbody tr:focus-within { background: var(--surface-raised); }
|
||
.numeric { text-align: right; }
|
||
.match { min-width: 200px; white-space: normal; }
|
||
.match-link { color: var(--accent-primary-hover); text-decoration: none; }
|
||
.match-link, .match-name { font-weight: var(--weight-medium); }
|
||
a:hover { text-decoration: underline; }
|
||
a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||
.instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||
.instance a { color: inherit; text-decoration: none; }
|
||
.status { color: var(--text-secondary); }
|
||
.status.active { color: var(--accent-primary-hover); }
|
||
.status.interrupted { color: var(--state-warning); }
|
||
.about { font-size: var(--text-xs); color: var(--text-secondary); }
|
||
.about p { max-width: 90ch; line-height: 1.6; }
|
||
.error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||
@media (max-width: 640px) { .filters > label, .filters > form { flex-basis: 45%; min-width: 120px; } .more-filters { padding-top: 0; width: 100%; } th, td { padding: 10px 12px; } }
|
||
</style>
|