Simplify match history with filters and persistent pagination

This commit is contained in:
2026-10-06 19:23:19 -04:00
parent 1370873cba
commit 711191c388
13 changed files with 419 additions and 103 deletions
+5 -5
View File
@@ -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]);
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
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 })]);
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
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(serverMatchHistory()).toEqual(history);
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');
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 () => {
@@ -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');
closeDb();
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,
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 () => {
+1 -1
View File
@@ -39,7 +39,7 @@ describe('completed match details', () => {
const { getMatchDetail, serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const match = getMatchDetail(1)!;
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({
id: 1, game: 't5', startObserved: true, server, status: 'completed',
startedAt: timestamp(0), endedAt: timestamp(3), durationMs: 180_000,
+80
View File
@@ -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');
});
+52
View File
@@ -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');
});