From 3c8b5b2e40456b57595627e5919e808bada07241 Mon Sep 17 00:00:00 2001 From: josh Date: Sun, 20 Sep 2026 21:44:36 -0400 Subject: [PATCH] Add global match history and ending timestamps --- src/lib/match-history.ts | 2 ++ src/lib/server/db/schema.sql | 1 + src/lib/server/fleet/match-history.ts | 13 ++++++++----- src/lib/ui/components/MatchHistory.svelte | 15 ++++++++++----- src/routes/+layout.svelte | 3 ++- src/routes/api/matches/+server.ts | 10 ++++++++++ src/routes/matches/+page.svelte | 15 +++++++++++++++ tests/unit/leaderboard-ingest.test.ts | 23 ++++++++++++++++++++--- 8 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 src/routes/api/matches/+server.ts create mode 100644 src/routes/matches/+page.svelte diff --git a/src/lib/match-history.ts b/src/lib/match-history.ts index 258e80d..6b854ec 100644 --- a/src/lib/match-history.ts +++ b/src/lib/match-history.ts @@ -1,7 +1,9 @@ export interface ServerMatch { id: number; + server: string; map: string; startedAt: string; + endedAt: string | null; status: 'active' | 'completed' | 'interrupted'; startPlayers: number | null; uniquePlayers: number; diff --git a/src/lib/server/db/schema.sql b/src/lib/server/db/schema.sql index 1cf360f..82ee6ee 100644 --- a/src/lib/server/db/schema.sql +++ b/src/lib/server/db/schema.sql @@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS leaderboard_matches ( last_fingerprint TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_leaderboard_server ON leaderboard_matches(server, id DESC); +CREATE INDEX IF NOT EXISTS idx_leaderboard_started ON leaderboard_matches(started_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_leaderboard_map ON leaderboard_matches(game, map_key); CREATE TABLE IF NOT EXISTS leaderboard_players ( diff --git a/src/lib/server/fleet/match-history.ts b/src/lib/server/fleet/match-history.ts index 37f2bc9..03a97da 100644 --- a/src/lib/server/fleet/match-history.ts +++ b/src/lib/server/fleet/match-history.ts @@ -1,10 +1,13 @@ import { db } from '../db/index.js'; import type { ServerMatch } from '../../match-history.js'; -export function serverMatchHistory(server: string, limit = 25): { matches: ServerMatch[]; total: number } { +export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } { const handle = db(); - const total = (handle.prepare('SELECT COUNT(*) AS total FROM leaderboard_matches WHERE server = ?').get(server) as { total: number }).total; - const matches = handle.prepare(`SELECT m.id, m.map_name AS map, m.started_at AS startedAt, m.status, + const where = server === undefined ? '' : 'WHERE m.server = ?'; + const parameters = server === undefined ? [] : [server]; + const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total; + const matches = handle.prepare(`SELECT m.id, m.server, 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, CASE WHEN m.status = 'completed' THEN @@ -13,7 +16,7 @@ export function serverMatchHistory(server: string, limit = 25): { matches: Serve CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN 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 - FROM leaderboard_matches m WHERE m.server = ? ORDER BY m.id DESC LIMIT ?`) - .all(server, limit) as ServerMatch[]; + FROM leaderboard_matches m ${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`) + .all(...parameters, limit) as ServerMatch[]; return { matches, total }; } diff --git a/src/lib/ui/components/MatchHistory.svelte b/src/lib/ui/components/MatchHistory.svelte index 5007d41..3245ece 100644 --- a/src/lib/ui/components/MatchHistory.svelte +++ b/src/lib/ui/components/MatchHistory.svelte @@ -2,21 +2,23 @@ import type { ServerMatch } from '$lib/match-history.js'; import { pollVisible } from '$ui/poll.js'; import { formatDuration } from '$ui/duration.js'; + import { serverTitle } from '$ui/server-labels.js'; 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 { server }: { server?: string } = $props(); let matches = $state([]); let total = $state(0); let limit = $state(25); let loading = $state(true); let error = $state(''); - async function refresh(currentServer: string, currentLimit: number) { + async function refresh(currentServer: string | undefined, currentLimit: number) { try { - const response = await fetch(`/api/servers/${encodeURIComponent(currentServer)}/matches?limit=${currentLimit}`); + 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; @@ -45,17 +47,19 @@ {#if loading} {:else if matches.length === 0 && !error} - + {:else if matches.length}
- + {#if server === undefined}{/if} {#each matches as match (match.id)} + + {#if server === undefined}{/if} @@ -79,4 +83,5 @@ th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; } th { color: var(--text-secondary); font-size: var(--text-xs); } td { font-variant-numeric: tabular-nums; } + a { color: var(--accent-primary-hover); } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3ea43cb..d3a957d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -99,7 +99,8 @@ { href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) }, { href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }, { href: '/settings', label: 'Settings', match: (p: string) => p.startsWith('/server-keys') || p.startsWith('/settings') }, - { href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') } + { href: '/leaderboard', label: 'Leaderboard', match: (p: string) => p.startsWith('/leaderboard') }, + { href: '/matches', label: 'Match History', match: (p: string) => p.startsWith('/matches') } ]; const secondary = $derived(tabs[1].match($page.url.pathname) ? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }] diff --git a/src/routes/api/matches/+server.ts b/src/routes/api/matches/+server.ts new file mode 100644 index 0000000..b6e2315 --- /dev/null +++ b/src/routes/api/matches/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; + +export function GET({ url }: { url: URL }) { + const limit = Number(url.searchParams.get('limit') ?? 25); + 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)); +} diff --git a/src/routes/matches/+page.svelte b/src/routes/matches/+page.svelte new file mode 100644 index 0000000..ec83028 --- /dev/null +++ b/src/routes/matches/+page.svelte @@ -0,0 +1,15 @@ + + +Match History · Electric Cherry + +
+ + +
+ + diff --git a/tests/unit/leaderboard-ingest.test.ts b/tests/unit/leaderboard-ingest.test.ts index f087a9b..3c8efcf 100644 --- a/tests/unit/leaderboard-ingest.test.ts +++ b/tests/unit/leaderboard-ingest.test.ts @@ -45,7 +45,7 @@ it('records server match counts, unique rejoins, duration, and final round durab const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js'); expect(serverMatchHistory(server)).toEqual({ total: 1, matches: [expect.objectContaining({ status: 'completed', startPlayers: 2, uniquePlayers: 3, endPlayers: 1, - durationMs: 3000, finalRound: 7 + durationMs: 3000, finalRound: 7, endedAt: '2026-09-18T20:00:03.000Z' })] }); expect(serverMatchHistory('other')).toEqual({ total: 0, matches: [] }); }); @@ -58,8 +58,8 @@ it('preserves unknown boundaries and distinguishes empty rosters from missing ob await report('game_start'); const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js'); expect(serverMatchHistory(server).matches).toEqual([ - expect.objectContaining({ status: 'active', startPlayers: 1, endPlayers: null, durationMs: null, finalRound: null }), - expect.objectContaining({ status: 'interrupted', startPlayers: 0, endPlayers: null, durationMs: null, finalRound: null }), + expect.objectContaining({ status: 'active', startPlayers: 1, endPlayers: null, durationMs: null, finalRound: null, endedAt: null }), + expect.objectContaining({ status: 'interrupted', startPlayers: 0, endPlayers: null, durationMs: null, finalRound: null, endedAt: null }), expect.objectContaining({ status: 'completed', startPlayers: null, uniquePlayers: 1, endPlayers: 0, durationMs: null, finalRound: 6 }) ]); const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js'); @@ -70,6 +70,23 @@ it('preserves unknown boundaries and distinguishes empty rosters from missing ob expect(GET({ params: { name: server }, url: new URL('http://localhost/?limit=-1') }).status).toBe(400); }); +it('lists global matches newest first across servers with totals and ending timestamps', async () => { + await report('game_start'); + await report('game_end', 8); + await report('game_start', 0, [player()], 't6_zombie_town_1', 'zm_transit'); + const { GET } = await import('../../src/routes/api/matches/+server.js'); + const all = await GET({ url: new URL('http://localhost/api/matches') }).json(); + expect(all.total).toBe(2); + expect(all.matches).toEqual([ + expect.objectContaining({ server: 't6_zombie_town_1', endedAt: null }), + expect.objectContaining({ server, endedAt: '2026-09-18T20:00:01.000Z' }) + ]); + const limited = await GET({ url: new URL('http://localhost/api/matches?limit=1') }).json(); + expect(limited.total).toBe(2); + expect(limited.matches).toEqual([all.matches[0]]); + expect(GET({ url: new URL('http://localhost/api/matches?limit=0') }).status).toBe(400); +}); + async function records(guid = '2741626') { const { db } = await import('../../src/lib/server/db/index.js'); return db().prepare('SELECT * FROM leaderboard_match_players WHERE guid = ? ORDER BY match_id').all(guid) as Array<{
StartedMapStatusPlayers at startUnique playersPlayers at endGame timeFinal round
StartedEndedServerMapStatusPlayers at startUnique playersPlayers at endGame timeFinal round
{#if match.endedAt}{:else}—{/if}{serverTitle(match.server)}{match.map} {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'} {match.startPlayers ?? '—'}{match.uniquePlayers}{match.endPlayers ?? '—'}