Add global match history and ending timestamps
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' }]
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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<{
|
||||||
|
|||||||
Reference in New Issue
Block a user