@@ -1,108 +1,177 @@
< 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 } from '$ui/stores/time-zone.js' ;
import type { ServerMatch } from '$lib/match-history.js' ;
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 HistoricalServerIdentity from './HistoricalServerIdentity.svelte' ;
import Card from './primitives/Card.svelte' ;
import Button from './primitives/Button.svelte' ;
import EmptyState from './primitives/EmptyState.svelte' ;
import ListFooter from './primitives/ListFooter.svelte' ;
let { server } : { server? : string } = $props ();
let matches = $state < ServerMatch [] >([] );
let total = $state ( 0 );
let limit = $state ( 25 );
$effect (() => { limit = Math . min ( 10000 , Math . max ( 25 , Number ( $page . url . searchParams . get ( 'matches' )) || 25 )); });
function saveContext() {
const url = new URL ( $page . url );
if ( limit > 25 ) url . searchParams . set ( 'matches' , String ( limit )); else url . searchParams . delete ( 'matches' );
void goto ( url , { replaceState : true , noScroll : true , keepFocus : true });
}
let loading = $state ( true );
let { server , initial } : { server? : string ; initial? : MatchHistoryResponse } = $props ();
let data = $state < MatchHistoryResponse | undefined >( untrack (() => initial ) );
let loading = $state ( ! untrack (() => initial ) );
let error = $state ( '' );
async function refresh ( currentServer : string | undefined , currentLimit : number ) {
try {
const endpoint = currentServer === undefined ? '/api/matches' : `/api/servers/ ${ encodeURIComponent ( currentServer ) } /matches` ;
const response = await fetch ( ` ${ endpoint } ?limit= ${ currentLimit } ` ) ;
if ( ! response . ok ) throw new Error ( 'Could not load match history.' );
const body = await response . json ();
if ( server !== currentServer || limit !== currentLimit ) return ;
matches = body . matches ;
total = body . total ;
error = '' ;
} catch ( cause ) {
if ( server === currentServer && limit === currentLimit ) error = ( cause as Error ). message ;
} finally {
if ( server === currentServer && limit === currentLimit ) loading = false ;
}
}
$effect (() => {
const currentServer = server ;
const currentLimit = limit ;
return pollVisible (() => refresh ( currentServer , currentLimit ), 10000 );
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 >
< Card padding = "lg ">
< h2 > Match history </ h2 >
{ #if error }
< div role = "alert" > { error } < Button onclick = {() => refresh ( server , limit )} > Retry</Button ></ div >
{ /if }
{ #if loading }
< EmptyState title = "Loading matches…" />
{ :else if matches . length === 0 && ! error }
< EmptyState title = "No matches recorded yet" description = { server === undefined ? 'Matches appear here when servers report gameplay.' : 'Matches appear here when this server reports gameplay.' } / >
{ :else if matches . length }
< 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" tabindex = "0" role = "region" aria-label = "Match history " >
< div class = "table-scroll" bind:this = { tableRegion } tabindex= "0" role = "region" aria-label = "Recorded matches " >
< table >
< thead >< tr >< th > Started </ th > { #if server === undefined } < th > Server </ th > { /if } < th > Map </ th >< th > Status </ th >< th > Unique players </ th >< th > Duration </ th >< th > Final round </ th ></ tr > </thead >
< tbody >
{ #each matches as match ( match . id )}
< tr class:completed = { match . status === 'completed' } >
< td >< time datetime = { match . startedAt } title= { $timeFormat . dateTime ( match . startedAt )} > { $timeFormat . period ( match . startedAt )} </ time ></ td >
{ #if server === undefined } < td >< HistoricalServerIdentity name = { match . server } deletedAt= { match . server_deleted_at } href = { `/servers/ ${ encodeURIComponent ( match . server ) } ?tab=matches` } / ></ td > { /if }
< td >
{ #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 }{ match . map }{ /if }
</ td >
< td > { match . status === 'active' ? 'In progress' : match . status === 'completed' ? 'Completed' : 'Interrupted' } </ td >
< td > { match . uniquePlayers } </ td >
< td > { match . durationMs === null ? '—' : formatDuration ( match . durationMs )} </ td >< td > { match . finalRound ?? '—' } </ td >
</ tr >
< 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 }
</ tbody >
</ 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 >
< ListFooter total = { Math . min ( total , 10000 )} bind:limit onchange = { saveContext } label=" matches" / >
{ #if total > 10000 } < p > Showing the latest 10,000 matches. </ p > { /if }
{ /if }
< details >< summary > About match history </ summary >< p > Newest matches first. Unique players are counted across all reports. — means a value was not recorded or the match is still in progress. Open a completed match for exact timestamps and player counts at the start and end. </ p ></ details >
</ Card >
< 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 >
h2 { margin : 0 ; font-size : var ( -- text - md ) ; }
p { color : var ( -- text - secondary ); font-size : var ( -- text - sm ); }
. table-scroll { overflow-x : auto ; }
. 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 : 145 px ; }
label { display : flex ; flex-direction : column ; gap : 6 px ; color : var ( -- text - secondary ); font-size : var ( -- text - xs ); }
input , select { width : 100 % ; min-height : 40 px ; padding : 8 px 10 px ; border : 1 px 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 : 25 px ; font-size : var ( -- text - sm ); }
. more-filters label { margin-top : 10 px ; }
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 : 1 px 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 ; font-size : var ( -- text - sm ); }
th , td { padding : var ( -- space -3 ) ; text-align : left ; border-bottom : 1 px solid var ( -- border - subtle ); white-space : nowrap ; }
th { color : var ( -- text - secondary ); font-size : var ( -- text - xs ); }
table { width : 100 % ; border-collapse : collapse ; background : var ( -- surface ); font-size : var ( -- text - sm ); }
th , td { padding : 12 px 16 px ; text-align : left ; border-bottom : 1 px 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 : 4 px 0 ; }
. text-button { color : var ( -- accent - primary - hover ); }
td { font-variant-numeric : tabular-nums ; }
. completed : hover , . completed : focus-within { background : var ( -- surface - raised ) ; }
. match-link { display : block ; color : var ( -- accent - primary - hover ); text-decoration : none ; }
. match-link : hover { text-decoration : underline ; }
. match-link : focus-visible { outline : none ; box-shadow : var ( -- focus - ring ); border-radius : var ( -- radius - sm ) ; }
details { margin-top : 12 px ; font-size : var ( -- text - xs ); color : var ( -- text - secondary ) ; }
summary { cursor : pointer ; }
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 : 200 px ; 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 : 2 px solid var ( -- accent - primary ); outline-offset : 3 px ; }
. instance , small { display : block ; margin-top : 4 px ; 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 : 90 ch ; 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 : 1 px ; height : 1 px ; padding : 0 ; margin : -1 px ; overflow : hidden ; clip-path : inset ( 50 % ); white-space : nowrap ; border : 0 ; }
@ media ( max-width : 640px ) { . filters > label , . filters > form { flex-basis : 45 % ; min-width : 120 px ; } . more-filters { padding-top : 0 ; width : 100 % ; } th , td { padding : 10 px 12 px ; } }
</ style >