diff --git a/src/lib/match-history-query.ts b/src/lib/match-history-query.ts new file mode 100644 index 0000000..722e0dc --- /dev/null +++ b/src/lib/match-history-query.ts @@ -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>): 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; +} diff --git a/src/lib/match-history.ts b/src/lib/match-history.ts index f6bbc9d..8b4b283 100644 --- a/src/lib/match-history.ts +++ b/src/lib/match-history.ts @@ -3,6 +3,8 @@ export const MIN_RECORDED_ROUND = 2; export interface ServerMatch { id: number; + game: string; + observedRound: number | null; server: string; server_deleted_at?: string | null; map: string; @@ -42,3 +44,11 @@ export interface MatchDetail extends ServerMatch { startObserved: boolean; players: MatchPlayer[]; } + +export interface MatchHistoryResponse { + matches: ServerMatch[]; + total: number; + page: number; + limit: number; + maps: Array<{ key: string; name: string; game: string }>; +} diff --git a/src/lib/server/fleet/match-history.ts b/src/lib/server/fleet/match-history.ts index 9963213..a057c04 100644 --- a/src/lib/server/fleet/match-history.ts +++ b/src/lib/server/fleet/match-history.ts @@ -1,7 +1,9 @@ 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, m.start_players AS startPlayers, (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, 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 where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`; - const parameters = server === undefined ? [] : [server]; + const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`]; + const parameters: Array = []; + 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 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 - ${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`) - .all(...parameters, limit) as ServerMatch[]; - return { matches, total }; + ${where} ORDER BY ${column} IS NULL ASC, ${column} ${direction}, m.id DESC LIMIT ? OFFSET ?`) + .all(...parameters, limit, (page - 1) * limit) as ServerMatch[]; + return { matches, total, page, limit, maps }; } /** Read durable match records without depending on a current server or scoreboard. */ export function getMatchDetail(id: number): MatchDetail | null { if (!Number.isSafeInteger(id) || id < 1) return null; 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' AND m.highest_round >= ${MIN_RECORDED_ROUND}`) .get(id) as (Omit & { startObserved: number }) | undefined; diff --git a/src/lib/ui/components/MatchHistory.svelte b/src/lib/ui/components/MatchHistory.svelte index 5022a07..24861a7 100644 --- a/src/lib/ui/components/MatchHistory.svelte +++ b/src/lib/ui/components/MatchHistory.svelte @@ -1,108 +1,177 @@ - -

Match history

- - {#if error} -
{error}
- {/if} - {#if loading} - - {:else if matches.length === 0 && !error} - - {:else if matches.length} +
+
+ {#if server === undefined} + + {/if} + + {#if server === undefined} +
{ event.preventDefault(); update({ q: search.trim() }); }}> + + Press Enter to search by instance name. +
+ {/if} + +
More filters{filters.status ? ' (1)' : ''} + +
+
+
Times in {$timeZone}{filters.days ? ` · Matches started in the last ${filters.days} days` : ''}{#if hasFilters}{/if}
+ {#if error}{/if} + {#if loading} + {:else if loadedUrl !== apiUrl && error} + {:else if data?.matches.length} -
+
- {#if server === undefined}{/if} - - {#each matches as match (match.id)} - - - {#if server === undefined}{/if} - - - - - + + + + + {#each sorts.filter(item => item.key !== 'started') as item} + {/each} - + + {#each data.matches as match (match.id)} + + + + + + + + + {/each}
StartedServerMapStatusUnique playersDurationFinal round
- {#if match.status === 'completed'} - - {match.map} - - {:else}{match.map}{/if} - {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.uniquePlayers}{match.durationMs === null ? '—' : formatDuration(match.durationMs)}{match.finalRound ?? '—'}
Matches sorted by {sorts.find(item => item.key === filters.sort)?.label.toLowerCase()}, {filters.direction === 'desc' ? 'highest or newest' : 'lowest or oldest'} first
MatchStatus
+ {#if match.status === 'completed'}{match.map} + {:else}{match.map}{/if} + {#if server === undefined}
+ {#if !match.server_deleted_at}{matchInstanceLabel(match)} + {:else}{matchInstanceLabel(match)} · Historical instance{/if} +
{/if} +
{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.uniquePlayers}{match.durationMs === null ? '—' : formatDuration(match.durationMs)}{match.status === 'completed' ? match.finalRound ?? '—' : match.observedRound ?? '—'}{#if match.status !== 'completed'}Highest observed{/if}
- - {#if total > 10000}

Showing the latest 10,000 matches.

{/if} - {/if} -
About match history

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.

- + + {:else if !error} + {#snippet actions()}{#if hasFilters}{/if}{/snippet} + {/if} + Players counts unique players observed throughout the match. +
About match history

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.

+
diff --git a/src/lib/ui/match-history.ts b/src/lib/ui/match-history.ts new file mode 100644 index 0000000..2e887ba --- /dev/null +++ b/src/lib/ui/match-history.ts @@ -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}`; +} diff --git a/src/routes/api/matches/+server.ts b/src/routes/api/matches/+server.ts index b6e2315..c4840c9 100644 --- a/src/routes/api/matches/+server.ts +++ b/src/routes/api/matches/+server.ts @@ -1,3 +1,4 @@ +import { readMatchQuery } from '$lib/match-history-query.js'; import { json } from '@sveltejs/kit'; 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) { 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)); } diff --git a/src/routes/api/servers/[name]/matches/+server.ts b/src/routes/api/servers/[name]/matches/+server.ts index a8829b7..2f848fa 100644 --- a/src/routes/api/servers/[name]/matches/+server.ts +++ b/src/routes/api/servers/[name]/matches/+server.ts @@ -1,3 +1,4 @@ +import { readMatchQuery } from '$lib/match-history-query.js'; import { json } from '@sveltejs/kit'; 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) { 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)); } diff --git a/src/routes/matches/+page.server.ts b/src/routes/matches/+page.server.ts new file mode 100644 index 0000000..860a06c --- /dev/null +++ b/src/routes/matches/+page.server.ts @@ -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) }; +} diff --git a/src/routes/matches/+page.svelte b/src/routes/matches/+page.svelte index ec83028..ae1ed7d 100644 --- a/src/routes/matches/+page.svelte +++ b/src/routes/matches/+page.svelte @@ -1,4 +1,5 @@ @@ -6,8 +7,8 @@ Match History · Electric Cherry
- - + +