Files
electric-cherry/src/lib/ui/components/MatchHistory.svelte
T

178 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>