Add global match history and ending timestamps

This commit is contained in:
2026-09-20 21:44:36 -04:00
parent fc681c56c9
commit 3c8b5b2e40
8 changed files with 68 additions and 14 deletions
+2
View File
@@ -1,7 +1,9 @@
export interface ServerMatch { export interface ServerMatch {
id: number; id: number;
server: string;
map: string; map: string;
startedAt: string; startedAt: string;
endedAt: string | null;
status: 'active' | 'completed' | 'interrupted'; status: 'active' | 'completed' | 'interrupted';
startPlayers: number | null; startPlayers: number | null;
uniquePlayers: number; uniquePlayers: number;
+1
View File
@@ -26,6 +26,7 @@ CREATE TABLE IF NOT EXISTS leaderboard_matches (
last_fingerprint TEXT NOT NULL 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_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 INDEX IF NOT EXISTS idx_leaderboard_map ON leaderboard_matches(game, map_key);
CREATE TABLE IF NOT EXISTS leaderboard_players ( CREATE TABLE IF NOT EXISTS leaderboard_players (
+8 -5
View File
@@ -1,10 +1,13 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import type { ServerMatch } from '../../match-history.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 handle = db();
const total = (handle.prepare('SELECT COUNT(*) AS total FROM leaderboard_matches WHERE server = ?').get(server) as { total: number }).total; const where = server === undefined ? '' : 'WHERE m.server = ?';
const matches = handle.prepare(`SELECT m.id, m.map_name AS map, m.started_at AS startedAt, m.status, 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, 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,
CASE WHEN m.status = 'completed' THEN 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 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, 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
FROM leaderboard_matches m WHERE m.server = ? ORDER BY m.id DESC LIMIT ?`) FROM leaderboard_matches m ${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
.all(server, limit) as ServerMatch[]; .all(...parameters, limit) as ServerMatch[];
return { matches, total }; return { matches, total };
} }
+10 -5
View File
@@ -2,21 +2,23 @@
import type { ServerMatch } from '$lib/match-history.js'; import type { ServerMatch } from '$lib/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 { serverTitle } from '$ui/server-labels.js';
import Card from './primitives/Card.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'; import ListFooter from './primitives/ListFooter.svelte';
let { server }: { server: string } = $props(); let { server }: { server?: string } = $props();
let matches = $state<ServerMatch[]>([]); let matches = $state<ServerMatch[]>([]);
let total = $state(0); let total = $state(0);
let limit = $state(25); let limit = $state(25);
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
async function refresh(currentServer: string, currentLimit: number) { async function refresh(currentServer: string | undefined, currentLimit: number) {
try { 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.'); if (!response.ok) throw new Error('Could not load match history.');
const body = await response.json(); const body = await response.json();
if (server !== currentServer || limit !== currentLimit) return; if (server !== currentServer || limit !== currentLimit) return;
@@ -45,17 +47,19 @@
{#if loading} {#if loading}
<EmptyState title="Loading matches…" /> <EmptyState title="Loading matches…" />
{:else if matches.length === 0 && !error} {:else if matches.length === 0 && !error}
<EmptyState title="No matches recorded yet" description="Matches appear here when this server reports gameplay." /> <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} {:else if 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" tabindex="0" role="region" aria-label="Match history">
<table> <table>
<thead><tr><th>Started</th><th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead> <thead><tr><th>Started</th><th>Ended</th>{#if server === undefined}<th>Server</th>{/if}<th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead>
<tbody> <tbody>
{#each matches as match (match.id)} {#each matches as match (match.id)}
<tr> <tr>
<td><time datetime={match.startedAt}>{new Date(match.startedAt).toLocaleString()}</time></td> <td><time datetime={match.startedAt}>{new Date(match.startedAt).toLocaleString()}</time></td>
<td>{#if match.endedAt}<time datetime={match.endedAt}>{new Date(match.endedAt).toLocaleString()}</time>{:else}—{/if}</td>
{#if server === undefined}<td><a href={`/servers/${encodeURIComponent(match.server)}?tab=matches`}>{serverTitle(match.server)}</a></td>{/if}
<td>{match.map}</td> <td>{match.map}</td>
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td> <td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td> <td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td>
@@ -79,4 +83,5 @@
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; } 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); } th { color: var(--text-secondary); font-size: var(--text-xs); }
td { font-variant-numeric: tabular-nums; } td { font-variant-numeric: tabular-nums; }
a { color: var(--accent-primary-hover); }
</style> </style>
+2 -1
View File
@@ -99,7 +99,8 @@
{ href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) }, { href: '/deployments', label: 'Operations', match: (p: string) => /^\/(deployments|campaigns)(\/|$)/.test(p) },
{ href: '/betas', label: 'Betas', match: (p: string) => p.startsWith('/betas') }, { 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: '/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) const secondary = $derived(tabs[1].match($page.url.pathname)
? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }] ? [{ href: '/scripts', label: 'Scripts' }, { href: '/configs', label: 'Configs' }, { href: '/plugins', label: 'Plugins' }]
+10
View File
@@ -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));
}
+15
View File
@@ -0,0 +1,15 @@
<script lang="ts">
import MatchHistory from '$ui/components/MatchHistory.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
</script>
<svelte:head><title>Match History · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader title="Match History" subtitle="Recorded matches across all servers." />
<MatchHistory />
</div>
<style>
.page { display: grid; gap: var(--space-6); min-width: 0; }
</style>
+20 -3
View File
@@ -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'); const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
expect(serverMatchHistory(server)).toEqual({ total: 1, matches: [expect.objectContaining({ expect(serverMatchHistory(server)).toEqual({ 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 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: [] });
}); });
@@ -58,8 +58,8 @@ it('preserves unknown boundaries and distinguishes empty rosters from missing ob
await report('game_start'); await report('game_start');
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).matches).toEqual([ expect(serverMatchHistory(server).matches).toEqual([
expect.objectContaining({ status: 'active', startPlayers: 1, 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 }), 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 }) 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'); 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); 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') { async function records(guid = '2741626') {
const { db } = await import('../../src/lib/server/db/index.js'); 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<{ return db().prepare('SELECT * FROM leaderboard_match_players WHERE guid = ? ORDER BY match_id').all(guid) as Array<{