Focus player records and add squad leaderboards

This commit is contained in:
2026-10-07 19:14:33 -04:00
parent bab78038dc
commit bcecc42dd1
22 changed files with 569 additions and 430 deletions
+51 -31
View File
@@ -11,6 +11,8 @@
import { 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));
@@ -18,6 +20,8 @@
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;
@@ -29,13 +33,14 @@
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 = ''; });
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
});
$effect(() => {
const url = apiUrl;
@@ -49,7 +54,7 @@
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 = '';
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.';
@@ -71,27 +76,33 @@
</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>
{#snippet filterFields(suffix: string)}
<div class="filters">
{#if server === undefined}<label for={`match-game${suffix}`}>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></label>{/if}
<label for={`match-map${suffix}`}>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></label>
{#if server === undefined}<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}>
<label for={`match-search${suffix}`}>Server<input id={`match-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Find a server…" aria-describedby={`match-search-help${suffix}`} /></label>
<span id={`match-search-help${suffix}`} class="sr-only">Press Enter to search by server name.</span>
</form>{/if}
<label for={`match-days${suffix}`}>Date range<select id={`match-days${suffix}`} 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>
<label for={`match-status${suffix}`}>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></select></label>
{#if suffix}
<label for={`match-sort${suffix}`}>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></label>
<label for={`match-order${suffix}`}>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></label>
{/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." />
@@ -112,11 +123,11 @@
<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>
{#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}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"><time datetime={match.startedAt}>{matchStartedLabel(match.startedAt, $timeZone)}</time> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
{#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}
<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={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td>
@@ -143,12 +154,10 @@
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 { 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; }
@@ -165,7 +174,6 @@
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); }
@@ -173,5 +181,17 @@
.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; } }
.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 > label, .filters > form { 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>