Simplify match history with filters and persistent pagination
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
export const matchPageSize = 25;
|
||||||
|
export const matchSorts = ['started', 'round', 'duration', 'players'] as const;
|
||||||
|
export type MatchSort = typeof matchSorts[number];
|
||||||
|
export interface MatchHistoryQuery {
|
||||||
|
game: string; map: string; q: string; days: number;
|
||||||
|
status: '' | 'active' | 'completed' | 'interrupted';
|
||||||
|
sort: MatchSort; direction: 'asc' | 'desc'; page: number;
|
||||||
|
}
|
||||||
|
export const matchQueryKeys = ['game', 'map', 'q', 'days', 'status', 'sort', 'direction', 'page'] as const;
|
||||||
|
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
||||||
|
const game = params.get('game') ?? '';
|
||||||
|
const map = params.get('map') ?? '';
|
||||||
|
const q = (params.get('q') ?? '').trim();
|
||||||
|
const days = Number(params.get('days') || 0);
|
||||||
|
const status = params.get('status') || '';
|
||||||
|
const sort = params.get('sort') || 'started';
|
||||||
|
const direction = params.get('direction') || 'desc';
|
||||||
|
const page = Number(params.get('page') || 1);
|
||||||
|
if (!['', 't5', 't6'].includes(game) || map.length > 1024 || q.length > 100
|
||||||
|
|| ![0, 7, 30, 90].includes(days) || !['', 'active', 'completed', 'interrupted'].includes(status)
|
||||||
|
|| !matchSorts.includes(sort as MatchSort) || !['asc', 'desc'].includes(direction)
|
||||||
|
|| !Number.isSafeInteger(page) || page < 1 || page > 1000000) throw new Error('Invalid match history filters.');
|
||||||
|
return { game, map, q, days, status: status as MatchHistoryQuery['status'], sort: sort as MatchSort, direction: direction as 'asc' | 'desc', page };
|
||||||
|
}
|
||||||
|
/** Namespaced filters also work inside an instance's Matches tab. */
|
||||||
|
export function matchApiParams(url: URL): URLSearchParams {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const key of matchQueryKeys) {
|
||||||
|
const value = url.searchParams.get(`match${key[0].toUpperCase()}${key.slice(1)}`);
|
||||||
|
if (value) params.set(key, value);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
export function matchHistoryHref(url: URL, changes: Partial<Record<keyof MatchHistoryQuery, string>>): string {
|
||||||
|
const next = new URL(url);
|
||||||
|
next.searchParams.delete('matches'); // Retire the former growing-list limit.
|
||||||
|
next.searchParams.delete('matchPage');
|
||||||
|
for (const [key, value] of Object.entries(changes)) {
|
||||||
|
const name = `match${key[0].toUpperCase()}${key.slice(1)}`;
|
||||||
|
if (value) next.searchParams.set(name, value); else next.searchParams.delete(name);
|
||||||
|
}
|
||||||
|
return next.pathname + next.search;
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ export const MIN_RECORDED_ROUND = 2;
|
|||||||
|
|
||||||
export interface ServerMatch {
|
export interface ServerMatch {
|
||||||
id: number;
|
id: number;
|
||||||
|
game: string;
|
||||||
|
observedRound: number | null;
|
||||||
server: string;
|
server: string;
|
||||||
server_deleted_at?: string | null;
|
server_deleted_at?: string | null;
|
||||||
map: string;
|
map: string;
|
||||||
@@ -42,3 +44,11 @@ export interface MatchDetail extends ServerMatch {
|
|||||||
startObserved: boolean;
|
startObserved: boolean;
|
||||||
players: MatchPlayer[];
|
players: MatchPlayer[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MatchHistoryResponse {
|
||||||
|
matches: ServerMatch[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
maps: Array<{ key: string; name: string; game: string }>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { MIN_RECORDED_ROUND, type MatchDetail, type MatchPlayer, type ServerMatch } from '../../match-history.js';
|
import { MIN_RECORDED_ROUND, type MatchDetail, type MatchPlayer, type MatchHistoryResponse, type ServerMatch } from '../../match-history.js';
|
||||||
|
|
||||||
const matchSummary = `m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
|
||||||
|
|
||||||
|
const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
||||||
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
||||||
m.start_players AS startPlayers,
|
m.start_players AS startPlayers,
|
||||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
||||||
@@ -12,22 +14,38 @@ const matchSummary = `m.id, m.server, m.server_deleted_at, m.map_name AS map, m.
|
|||||||
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
|
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
|
||||||
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound`;
|
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound`;
|
||||||
|
|
||||||
export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } {
|
export function serverMatchHistory(server?: string, limit = 25, query: MatchHistoryQuery = readMatchQuery(new URLSearchParams()), now = Date.now()): MatchHistoryResponse {
|
||||||
const handle = db();
|
const handle = db();
|
||||||
const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
|
const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`];
|
||||||
const parameters = server === undefined ? [] : [server];
|
const parameters: Array<string | number> = [];
|
||||||
|
if (server !== undefined) { clauses.push('m.server = ?'); parameters.push(server); }
|
||||||
|
if (query.game) { clauses.push('m.game = ?'); parameters.push(query.game); }
|
||||||
|
const maps = handle.prepare(`SELECT m.map_key AS key, MAX(m.map_name) AS name, m.game
|
||||||
|
FROM leaderboard_matches m WHERE ${clauses.join(' AND ')} GROUP BY m.map_key, m.game ORDER BY name COLLATE NOCASE, game`)
|
||||||
|
.all(...parameters) as MatchHistoryResponse['maps'];
|
||||||
|
if (query.map) { clauses.push('m.map_key = ?'); parameters.push(query.map); }
|
||||||
|
if (query.status) { clauses.push('m.status = ?'); parameters.push(query.status); }
|
||||||
|
if (query.days) { clauses.push('julianday(m.started_at) >= julianday(?)'); parameters.push(new Date(now - query.days * 86400000).toISOString()); }
|
||||||
|
// Search readable instance names as well as their stored identifiers. Tokens allow "Town Lobby 1".
|
||||||
|
for (const token of query.q.toLowerCase().replace(/\blobby\s+(?=\d+\b)/g, '').split(/[\s_]+/).filter(Boolean)) {
|
||||||
|
clauses.push("instr(lower(m.server || ' ' || m.map_name), ?) > 0"); parameters.push(token);
|
||||||
|
}
|
||||||
|
const where = `WHERE ${clauses.join(' AND ')}`;
|
||||||
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
|
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
|
||||||
|
const page = Math.min(query.page, Math.max(1, Math.ceil(total / limit)));
|
||||||
|
const column = { started: 'startedAt', round: 'observedRound', duration: 'durationMs', players: 'uniquePlayers' }[query.sort];
|
||||||
|
const direction = query.direction === 'asc' ? 'ASC' : 'DESC';
|
||||||
const matches = handle.prepare(`SELECT ${matchSummary} FROM leaderboard_matches m
|
const matches = handle.prepare(`SELECT ${matchSummary} FROM leaderboard_matches m
|
||||||
${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
|
${where} ORDER BY ${column} IS NULL ASC, ${column} ${direction}, m.id DESC LIMIT ? OFFSET ?`)
|
||||||
.all(...parameters, limit) as ServerMatch[];
|
.all(...parameters, limit, (page - 1) * limit) as ServerMatch[];
|
||||||
return { matches, total };
|
return { matches, total, page, limit, maps };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read durable match records without depending on a current server or scoreboard. */
|
/** Read durable match records without depending on a current server or scoreboard. */
|
||||||
export function getMatchDetail(id: number): MatchDetail | null {
|
export function getMatchDetail(id: number): MatchDetail | null {
|
||||||
if (!Number.isSafeInteger(id) || id < 1) return null;
|
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||||
const handle = db();
|
const handle = db();
|
||||||
const match = handle.prepare(`SELECT ${matchSummary}, m.game, m.start_observed AS startObserved
|
const match = handle.prepare(`SELECT ${matchSummary}, m.start_observed AS startObserved
|
||||||
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
|
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
|
||||||
AND m.highest_round >= ${MIN_RECORDED_ROUND}`)
|
AND m.highest_round >= ${MIN_RECORDED_ROUND}`)
|
||||||
.get(id) as (Omit<MatchDetail, 'players' | 'startObserved'> & { startObserved: number }) | undefined;
|
.get(id) as (Omit<MatchDetail, 'players' | 'startObserved'> & { startObserved: number }) | undefined;
|
||||||
|
|||||||
@@ -1,108 +1,177 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { contextHref } from '$lib/navigation-context.js';
|
import { contextHref } from '$lib/navigation-context.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
|
||||||
import type { ServerMatch } from '$lib/match-history.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 { pollVisible } from '$ui/poll.js';
|
||||||
import { formatDuration } from '$ui/duration.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 Button from './primitives/Button.svelte';
|
||||||
import EmptyState from './primitives/EmptyState.svelte';
|
import EmptyState from './primitives/EmptyState.svelte';
|
||||||
import ListFooter from './primitives/ListFooter.svelte';
|
|
||||||
|
|
||||||
let { server }: { server?: string } = $props();
|
let { server, initial }: { server?: string; initial?: MatchHistoryResponse } = $props();
|
||||||
let matches = $state<ServerMatch[]>([]);
|
let data = $state<MatchHistoryResponse | undefined>(untrack(() => initial));
|
||||||
let total = $state(0);
|
let loading = $state(!untrack(() => initial));
|
||||||
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 error = $state('');
|
let error = $state('');
|
||||||
|
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
|
||||||
async function refresh(currentServer: string | undefined, currentLimit: number) {
|
let retry = $state(0);
|
||||||
try {
|
let tableRegion = $state<HTMLDivElement>();
|
||||||
const endpoint = currentServer === undefined ? '/api/matches' : `/api/servers/${encodeURIComponent(currentServer)}/matches`;
|
let pendingUrl: URL | undefined;
|
||||||
const response = await fetch(`${endpoint}?limit=${currentLimit}`);
|
let navigationId = 0;
|
||||||
if (!response.ok) throw new Error('Could not load match history.');
|
const filters = $derived(readMatchQuery(matchApiParams($page.url)));
|
||||||
const body = await response.json();
|
const apiUrl = $derived.by(() => {
|
||||||
if (server !== currentServer || limit !== currentLimit) return;
|
const params = matchApiParams($page.url);
|
||||||
matches = body.matches;
|
params.set('limit', String(matchPageSize));
|
||||||
total = body.total;
|
const endpoint = server === undefined ? '/api/matches' : `/api/servers/${encodeURIComponent(server)}/matches`;
|
||||||
error = '';
|
return `${endpoint}?${params}`;
|
||||||
} 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 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>
|
</script>
|
||||||
|
|
||||||
<Card padding="lg">
|
<section class="history" aria-label="Match history">
|
||||||
<h2>Match history</h2>
|
<div class="filters">
|
||||||
|
{#if server === undefined}
|
||||||
{#if error}
|
<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>
|
||||||
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if loading}
|
<label>Map<select value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
|
||||||
<EmptyState title="Loading matches…" />
|
<option value="">All maps</option>
|
||||||
{:else if matches.length === 0 && !error}
|
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
|
||||||
<EmptyState title="No matches recorded yet" description={server === undefined ? 'Matches appear here when servers report gameplay.' : 'Matches appear here when this server reports gameplay.'} />
|
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game.toUpperCase()}` : ''}</option>{/each}
|
||||||
{:else if matches.length}
|
</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. -->
|
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
<!-- 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>
|
<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>
|
<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>
|
||||||
<tbody>
|
<thead><tr>
|
||||||
{#each matches as match (match.id)}
|
<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>
|
||||||
<tr class:completed={match.status === 'completed'}>
|
<th scope="col">Match</th><th scope="col">Status</th>
|
||||||
<td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{$timeFormat.period(match.startedAt)}</time></td>
|
{#each sorts.filter(item => item.key !== 'started') as item}
|
||||||
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
|
<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>
|
||||||
<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>
|
|
||||||
{/each}
|
{/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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<ListFooter total={Math.min(total, 10000)} bind:limit onchange={saveContext} label="matches" />
|
<div class="pagination"><span>{(data.page - 1) * data.limit + 1}–{(data.page - 1) * data.limit + data.matches.length} of {data.total} matches</span>
|
||||||
{#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if}
|
{#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}
|
||||||
{/if}
|
</div>
|
||||||
<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>
|
{: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.'}>
|
||||||
</Card>
|
{#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>
|
<style>
|
||||||
h2 { margin: 0; font-size: var(--text-md); }
|
.history { display: grid; gap: var(--space-4); min-width: 0; }
|
||||||
p { color: var(--text-secondary); font-size: var(--text-sm); }
|
.filters { display: flex; align-items: flex-start; flex-wrap: wrap; gap: var(--space-3); }
|
||||||
.table-scroll { overflow-x: auto; }
|
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-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
||||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); }
|
||||||
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
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); }
|
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; }
|
td { font-variant-numeric: tabular-nums; }
|
||||||
.completed:hover, .completed:focus-within { background: var(--surface-raised); }
|
tr:last-child td { border-bottom: 0; }
|
||||||
.match-link { display: block; color: var(--accent-primary-hover); text-decoration: none; }
|
tbody tr:hover, tbody tr:focus-within { background: var(--surface-raised); }
|
||||||
.match-link:hover { text-decoration: underline; }
|
.numeric { text-align: right; }
|
||||||
.match-link:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
.match { min-width: 200px; white-space: normal; }
|
||||||
details { margin-top: 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
.match-link { color: var(--accent-primary-hover); text-decoration: none; }
|
||||||
summary { cursor: pointer; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { serverIdentity } from './server-labels.js';
|
||||||
|
import type { ServerMatch } from '../match-history.js';
|
||||||
|
|
||||||
|
export function matchInstanceLabel(match: ServerMatch): string {
|
||||||
|
const identity = serverIdentity(match.server);
|
||||||
|
const parts = [identity.map !== match.map ? identity.map : '', identity.lobby === null ? '' : `Lobby ${identity.lobby}`,
|
||||||
|
match.game === 't5' ? 'Black Ops I' : match.game === 't6' ? 'Black Ops II' : match.game];
|
||||||
|
return parts.filter(Boolean).join(' · ') || match.server;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare calendar days in the display timezone, including DST boundaries. */
|
||||||
|
export function matchStartedLabel(value: string, zone: string, now = new Date()): string {
|
||||||
|
const date = new Date(/Z$|[+-]\d\d:\d\d$/.test(value) ? value : value.replace(' ', 'T') + 'Z');
|
||||||
|
if (!Number.isFinite(date.getTime())) return '—';
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, year: 'numeric', month: 'numeric', day: 'numeric' });
|
||||||
|
const day = (input: Date) => {
|
||||||
|
const values = Object.fromEntries(parts.formatToParts(input).map(part => [part.type, part.value]));
|
||||||
|
return Date.UTC(Number(values.year), Number(values.month) - 1, Number(values.day));
|
||||||
|
};
|
||||||
|
const difference = day(now) - day(date);
|
||||||
|
const dateLabel = difference === 0 ? 'Today' : difference === 86400000 ? 'Yesterday'
|
||||||
|
: new Intl.DateTimeFormat('en-US', { timeZone: zone, month: 'short', day: 'numeric',
|
||||||
|
...(parts.formatToParts(now).find(p => p.type === 'year')?.value !== parts.formatToParts(date).find(p => p.type === 'year')?.value ? { year: 'numeric' as const } : {}) }).format(date);
|
||||||
|
const clock = new Intl.DateTimeFormat('en-US', { timeZone: zone, hour: 'numeric', minute: '2-digit' }).format(date);
|
||||||
|
return `${dateLabel}, ${clock}`;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readMatchQuery } from '$lib/match-history-query.js';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
||||||
|
|
||||||
@@ -6,5 +7,8 @@ export function GET({ url }: { url: URL }) {
|
|||||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
||||||
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
||||||
}
|
}
|
||||||
return json(serverMatchHistory(undefined, limit));
|
let query;
|
||||||
|
try { query = readMatchQuery(url.searchParams); }
|
||||||
|
catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); }
|
||||||
|
return json(serverMatchHistory(undefined, limit, query));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { readMatchQuery } from '$lib/match-history-query.js';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
||||||
|
|
||||||
@@ -6,5 +7,8 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) {
|
|||||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
|
||||||
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
|
||||||
}
|
}
|
||||||
return json(serverMatchHistory(params.name, limit));
|
let query;
|
||||||
|
try { query = readMatchQuery(url.searchParams); }
|
||||||
|
catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); }
|
||||||
|
return json(serverMatchHistory(params.name, limit, query));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
|
||||||
|
import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.js';
|
||||||
|
export function load({ url }: { url: URL }) {
|
||||||
|
let query;
|
||||||
|
try { query = readMatchQuery(matchApiParams(url)); }
|
||||||
|
catch { error(400, 'Invalid match history filters.'); }
|
||||||
|
return { history: serverMatchHistory(undefined, matchPageSize, query) };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
let { data }: { data: import('./$types').PageData } = $props();
|
||||||
import MatchHistory from '$ui/components/MatchHistory.svelte';
|
import MatchHistory from '$ui/components/MatchHistory.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
</script>
|
</script>
|
||||||
@@ -6,8 +7,8 @@
|
|||||||
<svelte:head><title>Match History · Electric Cherry</title></svelte:head>
|
<svelte:head><title>Match History · Electric Cherry</title></svelte:head>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Match History" subtitle="Recorded matches across all servers." />
|
<SectionHeader title="Match History" subtitle="Recorded matches across your instances." />
|
||||||
<MatchHistory />
|
<MatchHistory initial={data.history} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ it('publishes round 2 or higher games only after game end, preserving early stat
|
|||||||
await report('round_change', 1, [player(undefined, { kills: 6, score: 1000 }), departed]);
|
await report('round_change', 1, [player(undefined, { kills: 6, score: 1000 }), departed]);
|
||||||
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
||||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||||
expect(serverMatchHistory()).toEqual({ total: 0, matches: [] });
|
expect(serverMatchHistory()).toEqual({ total: 0, matches: [], maps: [], page: 1, limit: 25 });
|
||||||
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
|
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
|
||||||
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
||||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||||
@@ -94,9 +94,9 @@ it('publishes round 2 or higher games only after game end, preserving early stat
|
|||||||
expect(getPlayerProfile(player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
|
expect(getPlayerProfile(player().guid)).toMatchObject({ stats: profile!.stats, maps: profile!.maps, matches: profile!.matches });
|
||||||
expect(serverMatchHistory()).toEqual(history);
|
expect(serverMatchHistory()).toEqual(history);
|
||||||
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||||
expect(await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json()).toEqual(history);
|
expect(await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json()).toEqual({ ...history, limit: 1 });
|
||||||
const { GET: serverGET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
|
const { GET: serverGET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
|
||||||
expect(await serverGET({ params: { name: server }, url: new URL('http://localhost/?limit=1') }).json()).toEqual(history);
|
expect(await serverGET({ params: { name: server }, url: new URL('http://localhost/?limit=1') }).json()).toEqual({ ...history, limit: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records server match counts, unique rejoins, duration, and final round durably', async () => {
|
it('records server match counts, unique rejoins, duration, and final round durably', async () => {
|
||||||
@@ -108,11 +108,11 @@ it('records server match counts, unique rejoins, duration, and final round durab
|
|||||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
closeDb();
|
closeDb();
|
||||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
expect(serverMatchHistory(server)).toEqual({ total: 1, matches: [expect.objectContaining({
|
expect(serverMatchHistory(server)).toMatchObject({ total: 1, matches: [expect.objectContaining({
|
||||||
status: 'completed', startPlayers: 2, uniquePlayers: 3, endPlayers: 1,
|
status: 'completed', startPlayers: 2, uniquePlayers: 3, endPlayers: 1,
|
||||||
durationMs: 3000, finalRound: 7, endedAt: '2026-09-18T20:00:03.000Z'
|
durationMs: 3000, finalRound: 7, endedAt: '2026-09-18T20:00:03.000Z'
|
||||||
})] });
|
})] });
|
||||||
expect(serverMatchHistory('other')).toEqual({ total: 0, matches: [] });
|
expect(serverMatchHistory('other')).toEqual({ total: 0, matches: [], maps: [], page: 1, limit: 25 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves unknown boundaries and distinguishes empty rosters from missing observations', async () => {
|
it('preserves unknown boundaries and distinguishes empty rosters from missing observations', async () => {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('completed match details', () => {
|
|||||||
const { getMatchDetail, serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
const { getMatchDetail, serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
const match = getMatchDetail(1)!;
|
const match = getMatchDetail(1)!;
|
||||||
const { game, startObserved, players, ...summary } = match;
|
const { game, startObserved, players, ...summary } = match;
|
||||||
expect(summary).toEqual(serverMatchHistory(server).matches[0]);
|
expect({ ...summary, game }).toEqual(serverMatchHistory(server).matches[0]);
|
||||||
expect({ ...summary, game, startObserved }).toMatchObject({
|
expect({ ...summary, game, startObserved }).toMatchObject({
|
||||||
id: 1, game: 't5', startObserved: true, server, status: 'completed',
|
id: 1, game: 't5', startObserved: true, server, status: 'completed',
|
||||||
startedAt: timestamp(0), endedAt: timestamp(3), durationMs: 180_000,
|
startedAt: timestamp(0), endedAt: timestamp(3), durationMs: 180_000,
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { readMatchQuery, matchHistoryHref, matchApiParams } from '../../src/lib/match-history-query.js';
|
||||||
|
let fixture: Fixture;
|
||||||
|
const now = Date.parse('2026-10-06T20:00:00Z');
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules(); fixture = await newFixture([]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const handle = db();
|
||||||
|
const match = handle.prepare(`INSERT INTO leaderboard_matches (id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at, status, start_observed, highest_round, last_event, last_fingerprint)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'game_end', 'test')`);
|
||||||
|
const player = handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?)`);
|
||||||
|
for (let id = 1; id <= 40; id++) {
|
||||||
|
const start = new Date(now - id * 86400000).toISOString();
|
||||||
|
const end = new Date(now - id * 86400000 + id * 60000).toISOString();
|
||||||
|
const game = id % 2 ? 't5' : 't6';
|
||||||
|
const name = game === 't5' ? 'Kino Der Toten' : 'Town Survival';
|
||||||
|
const map = game === 't5' ? 'theater' : 'town';
|
||||||
|
match.run(id, `${game}_zombie_${map}_1`, game, map, `${game}:${map}`, name, start, end, end,
|
||||||
|
id === 2 ? 'active' : id === 4 ? 'interrupted' : 'completed', id === 3 ? 0 : 1, id + 1);
|
||||||
|
for (let n = 0; n < id % 5; n++) player.run(id, String(n), start, end);
|
||||||
|
}
|
||||||
|
handle.prepare("UPDATE leaderboard_matches SET server_deleted_at = ? WHERE id = 1").run(new Date(now).toISOString());
|
||||||
|
});
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
const query = (value = '') => readMatchQuery(new URLSearchParams(value));
|
||||||
|
it('sorts the whole history before paging, keeps ties deterministic, and clamps empty pages', async () => {
|
||||||
|
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
const first = serverMatchHistory(undefined, 25, query());
|
||||||
|
const second = serverMatchHistory(undefined, 25, query('page=2'));
|
||||||
|
expect(first.total).toBe(40); expect(first.matches.map(m => m.id)).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
|
||||||
|
expect(second.matches.map(m => m.id)).toEqual(Array.from({ length: 15 }, (_, i) => i + 26));
|
||||||
|
expect(serverMatchHistory(undefined, 25, query('page=999')).page).toBe(2);
|
||||||
|
expect(serverMatchHistory(undefined, 25, query('sort=round')).matches[0].id).toBe(40);
|
||||||
|
expect(serverMatchHistory(undefined, 25, query('sort=players')).matches.slice(0, 3).map(m => m.id)).toEqual([39, 34, 29]);
|
||||||
|
});
|
||||||
|
it('combines game, map, readable instance search, status and date filters before counting', async () => {
|
||||||
|
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
const data = serverMatchHistory(undefined, 25, query('game=t6&map=t6%3Atown&q=Town+Lobby+1&status=completed&days=7'), now);
|
||||||
|
expect(data.total).toBe(1); expect(data.matches.map(m => m.id)).toEqual([6]);
|
||||||
|
expect(data.maps).toEqual([{ key: 't6:town', name: 'Town Survival', game: 't6' }]);
|
||||||
|
expect(serverMatchHistory(undefined, 25, query('q=%25')).total).toBe(0);
|
||||||
|
expect(serverMatchHistory('t5_zombie_theater_1', 25, query()).total).toBe(20);
|
||||||
|
const empty = serverMatchHistory(undefined, 25, query('q=missing&page=5'));
|
||||||
|
expect(empty).toMatchObject({ total: 0, matches: [], page: 1 });
|
||||||
|
});
|
||||||
|
it('keeps unknown durations last in either direction and retains historical and unfinished semantics', async () => {
|
||||||
|
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
for (const direction of ['asc', 'desc']) {
|
||||||
|
const data = serverMatchHistory(undefined, 100, query(`sort=duration&direction=${direction}`));
|
||||||
|
expect(data.matches.slice(-3).map(m => m.durationMs)).toEqual([null, null, null]);
|
||||||
|
}
|
||||||
|
const data = serverMatchHistory();
|
||||||
|
expect(data.matches[0].server_deleted_at).toBeTruthy();
|
||||||
|
expect(data.matches[1]).toMatchObject({ status: 'active', observedRound: 3, finalRound: null, durationMs: null });
|
||||||
|
expect(data.matches[3]).toMatchObject({ status: 'interrupted', observedRound: 5, finalRound: null, durationMs: null });
|
||||||
|
expect(data.matches.find(m => m.id === 5)?.uniquePlayers).toBe(0);
|
||||||
|
});
|
||||||
|
it('validates both API routes and loads the same filtered page on a direct page visit', async () => {
|
||||||
|
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||||
|
const { GET: instanceGET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
|
||||||
|
for (const value of ['page=-1', 'page=1.5', 'sort=sql', 'direction=sideways', 'game=t7', 'days=3', 'status=deleted', 'limit=0']) {
|
||||||
|
const url = new URL(`http://localhost/api/matches?${value}`);
|
||||||
|
expect(GET({ url }).status).toBe(400);
|
||||||
|
expect(instanceGET({ url, params: { name: 'anything' } }).status).toBe(400);
|
||||||
|
}
|
||||||
|
const { load } = await import('../../src/routes/matches/+page.server.js');
|
||||||
|
const url = new URL('http://localhost/matches?matchPage=2&matchSort=round');
|
||||||
|
const loaded = load({ url }).history;
|
||||||
|
const body = await GET({ url: new URL(`http://localhost/api/matches?${matchApiParams(url)}`) }).json();
|
||||||
|
expect(loaded).toEqual(body); expect(body.matches[0].id).toBe(15);
|
||||||
|
});
|
||||||
|
it('keeps return context and sorting while resetting page for a filter change', () => {
|
||||||
|
const url = new URL('http://localhost/servers/t5_zombie_theater_1?tab=matches&returnTo=%2Fhosts&matchSort=duration&matchDirection=asc&matchPage=3&matches=100');
|
||||||
|
const href = matchHistoryHref(url, { status: 'completed' });
|
||||||
|
expect(href).toContain('tab=matches'); expect(href).toContain('returnTo=%2Fhosts');
|
||||||
|
expect(href).toContain('matchSort=duration'); expect(href).toContain('matchDirection=asc');
|
||||||
|
expect(href).not.toContain('matchPage'); expect(href).not.toContain('matches=100');
|
||||||
|
expect(matchHistoryHref(url, { page: '4' })).toContain('matchPage=4');
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from 'svelte/server';
|
||||||
|
import { writable, type Writable } from 'svelte/store';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import MatchHistory from '../../src/lib/ui/components/MatchHistory.svelte';
|
||||||
|
import MatchHistoryPage from '../../src/routes/matches/+page.svelte';
|
||||||
|
import { matchStartedLabel, matchInstanceLabel } from '../../src/lib/ui/match-history.js';
|
||||||
|
import type { MatchHistoryResponse, ServerMatch } from '../../src/lib/match-history.js';
|
||||||
|
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/matches') }) }));
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
const match: ServerMatch = { id: 1, server: 't6_zombie_town_1', game: 't6', map: 'Town Survival', observedRound: 20, finalRound: 20, status: 'completed', startedAt: '2026-10-06T17:26:00Z', endedAt: '2026-10-06T18:04:00Z', durationMs: 2280000, startPlayers: 2, uniquePlayers: 4, endPlayers: 2 };
|
||||||
|
const report = (): MatchHistoryResponse => ({ matches: [match], total: 40, page: 1, limit: 25, maps: [{ key: 't6:town', game: 't6', name: 'Town Survival' }] });
|
||||||
|
function scope(search = '') { (page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost/matches${search}`) }); }
|
||||||
|
beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-06T20:00:00Z')); });
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
it('renders one page heading, six columns, a combined identity, and compact dates', () => {
|
||||||
|
const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body;
|
||||||
|
expect(body.match(/<h[12][ >]/g)).toHaveLength(1);
|
||||||
|
expect(body.match(/scope="col"/g)).toHaveLength(6);
|
||||||
|
expect(body).toContain('Today, 1:26 PM'); expect(body).toContain('Lobby 1 · Black Ops II');
|
||||||
|
expect(body).not.toContain('>Server</th>'); expect(body).not.toContain('Show 25 more');
|
||||||
|
expect(body).toContain('matchPage=2');
|
||||||
|
});
|
||||||
|
it('preserves filters and page in match links and avoids linking deleted instances', () => {
|
||||||
|
scope('?matchGame=t6&matchPage=2&matchSort=round&matchStatus=completed');
|
||||||
|
const data = report(); data.page = 2; data.matches = [{ ...match, server_deleted_at: '2026-10-06' }];
|
||||||
|
const body = render(MatchHistory, { props: { initial: data } }).body;
|
||||||
|
expect(body).toContain('/matches/1?returnTo=%2Fmatches'); expect(body).toContain('matchPage%3D2');
|
||||||
|
expect(body).toContain('matchSort%3Dround'); expect(body).toContain('Historical instance');
|
||||||
|
expect(body).not.toContain('href="/servers/'); expect(body).not.toContain('>Deleted<');
|
||||||
|
expect(body).toContain('26–26 of 40 matches'); expect(body).toContain('More filters (1)');
|
||||||
|
});
|
||||||
|
it('labels unfinished rounds and missing durations without inventing final results or detail links', () => {
|
||||||
|
const data = report(); data.matches = [{ ...match, status: 'interrupted', finalRound: null, durationMs: null, uniquePlayers: 0 }];
|
||||||
|
const body = render(MatchHistory, { props: { initial: data } }).body;
|
||||||
|
expect(body).toContain('Interrupted'); expect(body).toContain('Highest observed');
|
||||||
|
expect(body).not.toContain('href="/matches/1'); expect(body).toContain('>—</span>');
|
||||||
|
expect(body).toContain('>0</td>');
|
||||||
|
});
|
||||||
|
it('offers filter reset for an empty selection and retains the scoped instance view', () => {
|
||||||
|
scope('?matchQ=missing'); const data = report(); data.matches = []; data.total = 0;
|
||||||
|
const body = render(MatchHistory, { props: { initial: data } }).body;
|
||||||
|
expect(body).toContain('No matches found'); expect(body).toContain('Clear filters');
|
||||||
|
const scoped = render(MatchHistory, { props: { initial: report(), server: match.server } }).body;
|
||||||
|
expect(scoped).not.toContain('id="match-search"'); expect(scoped).not.toContain('Historical instance');
|
||||||
|
});
|
||||||
|
it('keeps custom instance names and calculates yesterday across DST and year boundaries', () => {
|
||||||
|
expect(matchInstanceLabel({ ...match, server: 'Weekend lobby' })).toBe('Weekend lobby · Black Ops II');
|
||||||
|
expect(matchStartedLabel('2026-03-08T06:30:00Z', 'America/New_York', new Date('2026-03-09T04:15:00Z'))).toBe('Yesterday, 1:30 AM');
|
||||||
|
expect(matchStartedLabel('2025-10-06T17:26:00Z', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Oct 6, 2025, 1:26 PM');
|
||||||
|
expect(matchStartedLabel('2026-10-06 17:26:00', 'America/New_York', new Date('2026-10-06T20:00:00Z'))).toBe('Today, 1:26 PM');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user