Files
electric-cherry/src/lib/ui/components/MatchHistory.svelte
T
2026-10-12 00:58:05 -04:00

693 lines
28 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 Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { goto } from '$app/navigation';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { uiNow } from '$ui/stores/servers.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 } from '$ui/match-history.js';
import { pollVisible } from '$ui/poll.js';
import { elapsedSince, formatDuration } from '$ui/duration.js';
import Button from './primitives/Button.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import Dialog from './primitives/Dialog.svelte';
import Freshness from './Freshness.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 filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => (initial ? Date.now() : null)));
let tableRegion = $state<HTMLDivElement>();
let pendingUrl: URL | undefined;
let navigationId = 0;
const now = $derived(browser ? $uiNow : Date.now());
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 filterCount = $derived(
[filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length
);
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 = '';
checkedAt = Date.now();
});
});
$effect(() => {
const url = apiUrl;
void 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 = '';
checkedAt = Date.now();
} 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">
{#snippet filterFields(suffix: string)}
<div class="filters">
{#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
<Select
id={`match-game${suffix}`}
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
></Field
>{/if}
<Field forId={`match-map${suffix}`} label="Map">
<Select
id={`match-map${suffix}`}
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 === 't5' ? 'BO1' : 'BO2'}`
: ''}</option
>{/each}
</Select></Field
>
{#if server === undefined}<form
role="search"
onsubmit={(event) => {
event.preventDefault();
update({ q: search.trim() });
if (suffix) filtersOpen = false;
}}
>
<Field forId={`match-search${suffix}`} label="Server">
<Input
id={`match-search${suffix}`}
type="search"
bind:value={search}
maxlength={100}
placeholder="Find a server…"
aria-describedby={`match-search-help${suffix}`}
/></Field
>
<span id={`match-search-help${suffix}`} class="sr-only"
>Press Enter to search by server name.</span
>
</form>{/if}
<Field forId={`match-days${suffix}`} label="Date range">
<Select
id={`match-days${suffix}`}
value={String(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
></Field
>
<Field forId={`match-status${suffix}`} label="Status">
<Select
id={`match-status${suffix}`}
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
><option value="end-report-missing">End report missing</option></Select
></Field
>
{#if suffix}
<Field forId={`match-sort${suffix}`} label="Sort by">
<Select
id={`match-sort${suffix}`}
value={filters.sort}
onchange={(event) => update({ sort: event.currentTarget.value })}
>{#each sorts as item}<option value={item.key}>{item.label}</option
>{/each}</Select
></Field
>
<Field forId={`match-order${suffix}`} label="Order">
<Select
id={`match-order${suffix}`}
value={filters.direction}
onchange={(event) => update({ direction: event.currentTarget.value })}
><option value="desc">Highest or newest first</option><option value="asc"
>Lowest or oldest first</option
></Select
></Field
>
{/if}
</div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<div class="mobile-filters">
<span
>{data?.total ?? '—'} matches{filters.map
? ` · ${data?.maps.find((map) => map.key === filters.map)?.name ?? 'Selected map'}`
: ''}</span
><Button size="sm" onclick={() => (filtersOpen = true)}
>Filters{filterCount ? ` · ${filterCount}` : ''}</Button
>
</div>
<Dialog
bind:open={filtersOpen}
title="Match filters"
description="Choose the matches to show."
drawer
>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button
variant="primary"
onclick={() => (filtersOpen = false)}>Show results</Button
>{/snippet}
</Dialog>
<div class="context">
<span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span
>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button
>{/if}{#if !error}<Freshness
updatedAt={checkedAt}
intervalLabel="Updates every 10 seconds"
/>{/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; in-progress and 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)}
{@const reporting =
match.status === 'active' &&
!match.server_deleted_at &&
freshStatusTimestamp(timestampISO(match.lastReportedAt) ?? null, now)}
{@const notReporting = match.status === 'active' && !reporting}
{@const status = notReporting
? 'Not reporting'
: match.status === 'active'
? 'In progress'
: match.status === 'completed'
? 'Completed'
: match.status === 'end-report-missing'
? 'End report missing'
: 'Interrupted'}
{@const duration =
match.status === 'active'
? reporting
? elapsedSince(timestampISO(match.startedAt) ?? null, now)
: null
: match.durationMs === null
? null
: formatDuration(match.durationMs)}
<tr>
<td><RelativeTime value={match.startedAt} /></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 ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a
>
{:else if reporting}<a
class="match-link"
href={contextHref(
`/servers/${encodeURIComponent(match.server)}`,
$page.url
)}
aria-label={`View live ${match.map} match on ${matchInstanceLabel(match)}`}
>{match.map}</a
>
{:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"
><RelativeTime value={match.startedAt} /> · {status}{duration !==
null
? ` · ${duration}`
: ''}</small
>
{#if notReporting}<small class="mobile-match-context"
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
/></small
>{/if}
{#if match.missingReports !== null && match.missingReports > 0}<small
class="mobile-match-context"
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
>{/if}
{#if server === undefined}<div class="instance">
<span title={match.server}>{matchInstanceLabel(match)}</span
>{#if match.server_deleted_at}<span>
· Historical instance</span
>{/if}
</div>{/if}
</td>
<td
><span
class="status"
class:active={reporting}
class:interrupted={match.status === 'interrupted' ||
match.status === 'end-report-missing' ||
notReporting}>{status}</span
>{#if notReporting}<small
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
/></small
>{/if}{#if match.missingReports !== null && match.missingReports > 0}<small
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
>{/if}</td
>
<td class="numeric">{match.uniquePlayers}</td>
<td class="numeric"
><span
title={duration === null
? 'Duration was not recorded.'
: undefined}>{duration ?? '—'}</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. In-progress matches show elapsed time since their recorded start and link to
the live server while reports are current. Otherwise, they show Not reporting with the
last report time. Completed durations require a recorded start and completed ending. End
report missing means a newer game was reported before this game's end report arrived.
Missing report counts show gaps in the reporter's sequence and can decrease as delayed
reports arrive. Only completed games contribute to the leaderboard. 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);
}
form {
flex: 1;
min-width: 145px;
}
.filters :global(.field) {
flex: 1;
min-width: 145px;
}
.filters :global(.field) {
flex-direction: column;
}
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 {
position: relative;
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);
}
.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;
}
.mobile-filters,
.mobile-match-context {
display: none;
}
@media (max-width: 640px) {
.desktop-filters {
display: none;
}
.mobile-match-context {
display: block;
}
.mobile-filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
font-size: var(--text-sm);
}
.filters {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.filters > form {
min-width: 0;
}
.filters > :global(.field) {
min-width: 0;
}
.context {
gap: var(--space-1) var(--space-2);
}
th,
td {
padding: 10px 12px;
}
th:nth-child(1),
td:nth-child(1) {
display: none;
}
th:nth-child(3),
td:nth-child(3),
th:nth-child(5),
td:nth-child(5) {
display: none;
}
.match {
min-width: 130px;
}
}
</style>