Focus player records and add squad leaderboards

This commit is contained in:
2026-10-07 19:14:33 -04:00
parent bab78038dc
commit bcecc42dd1
22 changed files with 569 additions and 430 deletions
+16 -12
View File
@@ -1,10 +1,14 @@
# Leaderboard # Leaderboard
Open **Leaderboard** in the main navigation to compare players across the fleet, Open **Leaderboard** in the main navigation to compare players across the fleet,
with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game, map,
and map filters limit which matches contribute to each player's statistics and and starting squad-size filters limit which matches contribute to each player's statistics and
rank. Choose a ranking, search a name or GUID, and share the page URL with its rank. Choose a ranking, search a name or GUID, and share the page URL with its
filters intact. Results refresh while the page is visible. filters intact. Results refresh while the page is visible. The page opens on a
recorded map by default; choose **All maps** for a fleet-wide comparison. A URL
with `map=` explicitly selects all maps. **Players** selects Solo, Duo, Trio or
Four players using the recorded starting roster; unknown starting counts appear
only under All squad sizes. The JSON API still defaults to all maps.
## Qualifying matches ## Qualifying matches
@@ -29,13 +33,13 @@ rounds per game, and completion rate. Each map has its own complete set of
records; map records and match history identify their game. Match history shows records; map records and match history identify their game. Match history shows
individual counters, server, date and completion credit in pages of 25 matches individual counters, server, date and completion credit in pages of 25 matches
across both games. Map names link back to that map's leaderboard in its game. across both games. Map names link back to that map's leaderboard in its game.
Use **Refresh stats** to load the latest reports. Ratios with a zero denominator Profiles update every 30 seconds while visible; the freshness indicator offers Retry if an update fails. Ratios with a zero denominator
display a dash. Lifetime statistics and per-map records always cover all recorded display a dash. Lifetime statistics and per-map records always cover all recorded
maps across both games. maps across both games.
Opening a player from the leaderboard preserves the originating game, map, Opening a player from the leaderboard preserves the originating game, map, squad size,
metric, search and page in the **Leaderboard** return link. The profile's metric, search and page in the **Leaderboard** return link. The profile's
**Selected leaderboard** controls initialize from that game/map/metric and show **Filters** controls initialize from that game/map/squad/metric and show
the player's rank within that competition, independent of the original search. the player's rank within that competition, independent of the original search.
These controls also filter match history; lifetime totals stay unchanged. These controls also filter match history; lifetime totals stay unchanged.
History pagination retains the selection and return link. Map links preserve History pagination retains the selection and return link. Map links preserve
@@ -43,9 +47,9 @@ the selected ranking metric. A player without qualifying matches in a selection
can still view their lifetime profile and choose another scope. can still view their lifetime profile and choose another scope.
On phones, per-map records and match history use compact cards with the map, On phones, per-map records and match history use compact cards with the map,
game, highest round and kills up front. Expand **All stats and record runs** game, record values and match identity up front. Record values link directly to
or **More match stats** for the remaining counters and source evidence. Desktop their completed match, where the full evidence is available. Desktop profiles
profiles retain the complete tables; both views use the same history filters use compact tables; both views use the same history filters
and pagination. History dates say **First observed**, since a missing start and pagination. History dates say **First observed**, since a missing start
report can make the first recorded observation later than the actual game start. report can make the first recorded observation later than the actual game start.
@@ -53,9 +57,8 @@ report can make the first recorded observation later than the actual game start.
Highest-round, best-game-kill and highest-score records link to the completed Highest-round, best-game-kill and highest-score records link to the completed
match that earned them on the leaderboard and in lifetime/per-map profile stats. match that earned them on the leaderboard and in lifetime/per-map profile stats.
Each source shows its map, game, completion time, server, and whether the match Each source names its match and map. Open the match to see completion time,
start was recorded. **Run details** shows when the player was first observed: server, whether its start was recorded, and player participation evidence.
at the recorded start, at a reported round, or unknown for older stored records.
Presence is not proof of continuous survival. Equal personal bests link to the Presence is not proof of continuous survival. Equal personal bests link to the
earliest recorded completion, then the lowest match ID when times tie. earliest recorded completion, then the lowest match ID when times tie.
@@ -206,6 +209,7 @@ Use URL encoding for map keys and search text.
| --- | --- | --- | | --- | --- | --- |
| `game` | Both games | Exactly `t5` or `t6`. An empty value is invalid. | | `game` | Both games | Exactly `t5` or `t6`. An empty value is invalid. |
| `map` | All maps | Opaque `maps[].key`, at most 1024 characters. Empty means no map filter. An unknown key or a game/map mismatch returns an empty selection, not an error. | | `map` | All maps | Opaque `maps[].key`, at most 1024 characters. Empty means no map filter. An unknown key or a game/map mismatch returns an empty selection, not an error. |
| `players` | All squad sizes | Exactly `1`, `2`, `3`, or `4`, matching the recorded starting player count. Unknown counts contribute only when this filter is absent. |
| `metric` | `highestRound` | One of the metric keys below. An empty or unknown value is invalid. | | `metric` | `highestRound` | One of the metric keys below. An empty or unknown value is invalid. |
| `q` | No search | At most 100 characters before trimming. Matches a literal substring of the current name or GUID, ignoring ASCII letter case. Surrounding whitespace is trimmed; whitespace-only input means no search. `%` and `_` are literal characters, not wildcards. | | `q` | No search | At most 100 characters before trimming. Matches a literal substring of the current name or GUID, ignoring ASCII letter case. Surrounding whitespace is trimmed; whitespace-only input means no search. `%` and `_` are literal characters, not wildcards. |
| `limit` | `50` | Integer from 1 to 100. | | `limit` | `50` | Integer from 1 to 100. |
+6 -4
View File
@@ -1,7 +1,7 @@
import { leaderboardMetrics } from './leaderboard.js'; import { leaderboardMetrics } from './leaderboard.js';
const origin = 'https://electric-cherry.invalid'; const origin = 'https://electric-cherry.invalid';
const contextKeys = ['game', 'map', 'metric', 'q', 'offset'] as const; const contextKeys = ['game', 'map', 'players', 'metric', 'q', 'offset'] as const;
/** Only retain a local leaderboard destination and its supported view state. */ /** Only retain a local leaderboard destination and its supported view state. */
export function leaderboardReturnHref(value: string | null | undefined): string { export function leaderboardReturnHref(value: string | null | undefined): string {
@@ -12,7 +12,7 @@ export function leaderboardReturnHref(value: string | null | undefined): string
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const key of contextKeys) { for (const key of contextKeys) {
const entry = url.searchParams.get(key); const entry = url.searchParams.get(key);
if (entry) params.set(key, entry); if (entry || key === 'map' && entry !== null) params.set(key, entry!);
} }
return `/leaderboard${params.size ? `?${params}` : ''}`; return `/leaderboard${params.size ? `?${params}` : ''}`;
} catch { return '/leaderboard'; } } catch { return '/leaderboard'; }
@@ -26,9 +26,11 @@ export function playerContextHref(guid: string, source: string): string {
const game = original.get('game'); const game = original.get('game');
const map = original.get('map'); const map = original.get('map');
const metric = original.get('metric'); const metric = original.get('metric');
const players = original.get('players');
// Match the leaderboard's displayed defaults for older or hand-edited URLs. // Match the leaderboard's displayed defaults for older or hand-edited URLs.
if (game === 't5' || game === 't6') params.set('game', game); if (game === 't5' || game === 't6') params.set('game', game);
if (map && map.length <= 1024) params.set('map', map); if (map !== null && map.length <= 1024) params.set('map', map);
if (players && /^[1-4]$/.test(players)) params.set('players', players);
if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric); if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric);
return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`; return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`;
} }
@@ -37,7 +39,7 @@ export function profileViewHref(current: URL, changes: Record<string, string>):
const params = new URLSearchParams(current.search); const params = new URLSearchParams(current.search);
params.delete('offset'); params.delete('offset');
for (const [key, value] of Object.entries(changes)) { for (const [key, value] of Object.entries(changes)) {
if (value) params.set(key, value); if (value || key === 'map') params.set(key, value);
else params.delete(key); else params.delete(key);
} }
return `${current.pathname}${params.size ? `?${params}` : ''}`; return `${current.pathname}${params.size ? `?${params}` : ''}`;
+3
View File
@@ -101,9 +101,12 @@ export interface LeaderboardMap {
name: string; name: string;
} }
export type LeaderboardPlayerCount = 1 | 2 | 3 | 4;
export interface LeaderboardOptions { export interface LeaderboardOptions {
game?: string; game?: string;
map?: string; map?: string;
players?: LeaderboardPlayerCount;
metric?: LeaderboardMetric; metric?: LeaderboardMetric;
q?: string; q?: string;
limit?: number; limit?: number;
+6 -4
View File
@@ -1,11 +1,12 @@
import type Database from 'better-sqlite3'; import type Database from 'better-sqlite3';
import type { PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js'; import type { LeaderboardPlayerCount, PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js'; import { MIN_RECORDED_ROUND } from '../../match-history.js';
interface RecordRequest extends Record<PersonalBestMetric, number> { interface RecordRequest extends Record<PersonalBestMetric, number> {
guid: string; guid: string;
game?: string; game?: string;
map?: string; map?: string;
players?: LeaderboardPlayerCount;
} }
/** Fetch evidence only for requested players and scopes, never rank the whole fleet's history. */ /** Fetch evidence only for requested players and scopes, never rank the whole fleet's history. */
@@ -15,9 +16,9 @@ export function getPersonalBestRecords(handle: Database.Database, requests: Reco
for (let offset = 0; offset < requests.length; offset += 100) { for (let offset = 0; offset < requests.length; offset += 100) {
const batch = requests.slice(offset, offset + 100); const batch = requests.slice(offset, offset + 100);
const parameters = batch.flatMap((request, index) => [index, request.guid, request.game ?? null, const parameters = batch.flatMap((request, index) => [index, request.guid, request.game ?? null,
request.map ?? null, request.highestRound, request.bestKills, request.bestScore]); request.map ?? null, request.players ?? null, request.highestRound, request.bestKills, request.bestScore]);
const rows = handle.prepare(`WITH requested (requestId, guid, game, map, highestRound, bestKills, bestScore) AS ( const rows = handle.prepare(`WITH requested (requestId, guid, game, map, players, highestRound, bestKills, bestScore) AS (
VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')} VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}
), candidates AS ( ), candidates AS (
SELECT r.requestId, m.id AS matchId, m.game, m.map_key AS mapKey, m.map_name AS mapName, SELECT r.requestId, m.id AS matchId, m.game, m.map_key AS mapKey, m.map_name AS mapName,
m.server, m.started_at AS startedAt, m.ended_at AS endedAt, m.start_observed AS startObserved, m.server, m.started_at AS startedAt, m.ended_at AS endedAt, m.start_observed AS startObserved,
@@ -30,6 +31,7 @@ export function getPersonalBestRecords(handle: Database.Database, requests: Reco
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.has_stats = 1 WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.has_stats = 1
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND} AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}
AND (r.game IS NULL OR m.game = r.game) AND (r.map IS NULL OR m.map_key = r.map) AND (r.game IS NULL OR m.game = r.game) AND (r.map IS NULL OR m.map_key = r.map)
AND (r.players IS NULL OR m.start_players = r.players)
AND (mp.highest_round = r.highestRound OR mp.kills = r.bestKills OR mp.best_score = r.bestScore) AND (mp.highest_round = r.highestRound OR mp.kills = r.bestKills OR mp.best_score = r.bestScore)
), valuesByMetric AS ( ), valuesByMetric AS (
SELECT *, 'highestRound' AS metric, highestRound AS value FROM candidates WHERE highestRound = targetRound SELECT *, 'highestRound' AS metric, highestRound AS value FROM candidates WHERE highestRound = targetRound
+20 -5
View File
@@ -32,6 +32,20 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
return metrics.has(value); return metrics.has(value);
} }
/** Pick a usable initial map without ranking every player across the fleet first. */
export function defaultLeaderboardMap(options: Pick<LeaderboardOptions, 'game' | 'players'> = {}): string | null {
const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", `m.highest_round >= ${MIN_RECORDED_ROUND}`,
`EXISTS (SELECT 1 FROM leaderboard_match_players mp WHERE mp.match_id = m.id
AND mp.has_stats = 1 AND mp.highest_round >= ${MIN_RECORDED_ROUND})`];
const parameters: Array<string | number> = [];
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
if (options.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); }
const row = db().prepare(`SELECT m.map_key AS key FROM leaderboard_matches m
WHERE ${conditions.join(' AND ')} ORDER BY m.game, m.map_name COLLATE NOCASE, m.map_key, m.id DESC LIMIT 1`)
.get(...parameters) as { key: string } | undefined;
return row?.key ?? null;
}
/** Rank each GUID once within the selected matches; player search keeps their actual rank. */ /** Rank each GUID once within the selected matches; player search keeps their actual rank. */
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse { export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
return readLeaderboard(options); return readLeaderboard(options);
@@ -39,13 +53,13 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
/** Select the exact player after ranking everyone eligible in the requested scope. */ /** Select the exact player after ranking everyone eligible in the requested scope. */
export function getPlayerLeaderboardEntry(guid: string, export function getPlayerLeaderboardEntry(guid: string,
options: Pick<LeaderboardOptions, 'game' | 'map' | 'metric'> = {}): LeaderboardEntry | null { options: Pick<LeaderboardOptions, 'game' | 'map' | 'players' | 'metric'> = {}): LeaderboardEntry | null {
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid); return readLeaderboard({ game: options.game, map: options.map, players: options.players, metric: options.metric, limit: 1 }, guid);
} }
function addRecordSources(handle: ReturnType<typeof db>, entries: RankedEntry[], options: LeaderboardOptions): LeaderboardEntry[] { function addRecordSources(handle: ReturnType<typeof db>, entries: RankedEntry[], options: LeaderboardOptions): LeaderboardEntry[] {
const records = getPersonalBestRecords(handle, entries.map(entry => ({ const records = getPersonalBestRecords(handle, entries.map(entry => ({
...entry, game: options.game || undefined, map: options.map || undefined ...entry, game: options.game || undefined, map: options.map || undefined, players: options.players
}))); })));
return entries.map((entry, index) => ({ ...entry, records: records[index] })); return entries.map((entry, index) => ({ ...entry, records: records[index] }));
} }
@@ -59,9 +73,10 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0; const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0;
const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", 'mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`, const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", 'mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`,
`mp.highest_round >= ${MIN_RECORDED_ROUND}`]; `mp.highest_round >= ${MIN_RECORDED_ROUND}`];
const parameters: string[] = []; const parameters: Array<string | number> = [];
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); } if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); } if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); }
if (options.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); }
const qualification = isEfficiencyMetric(metric) const qualification = isEfficiencyMetric(metric)
? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL` ? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL`
+ (metric === 'headshotRate' ? ` AND measures.kills >= ${EFFICIENCY_MIN_KILLS}` : '') + (metric === 'headshotRate' ? ` AND measures.kills >= ${EFFICIENCY_MIN_KILLS}` : '')
@@ -101,7 +116,7 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
catch { /* Standalone history tools can operate without cache metadata. */ } catch { /* Standalone history tools can operate without cache metadata. */ }
let cache = scopeCaches.get(handle); let cache = scopeCaches.get(handle);
if (!cache) { cache = new Map(); scopeCaches.set(handle, cache); } if (!cache) { cache = new Map(); scopeCaches.set(handle, cache); }
const cacheKey = JSON.stringify([options.game ?? '', options.map ?? '', metric]); const cacheKey = JSON.stringify([options.game ?? '', options.map ?? '', options.players ?? '', metric]);
let saved = cache.get(cacheKey); let saved = cache.get(cacheKey);
if (!saved || revision < 0 || saved.revision !== revision) { if (!saved || revision < 0 || saved.revision !== revision) {
const rows = handle.prepare(`${aggregated}, playerSummary AS ( const rows = handle.prepare(`${aggregated}, playerSummary AS (
+4 -3
View File
@@ -12,7 +12,7 @@ const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`; AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`;
export function getPlayerProfile(guid: string, requestedOffset = 0, export function getPlayerProfile(guid: string, requestedOffset = 0,
options: Pick<LeaderboardOptions, 'game' | 'map'> = {}): PlayerProfile | null { options: Pick<LeaderboardOptions, 'game' | 'map' | 'players'> = {}): PlayerProfile | null {
const handle = db(); const handle = db();
// Keep game-specific ingestion identities, but show one identity with the latest reported name. // Keep game-specific ingestion identities, but show one identity with the latest reported name.
const player = handle.prepare(`SELECT guid, name, MIN(first_seen_at) OVER () AS firstSeen, const player = handle.prepare(`SELECT guid, name, MIN(first_seen_at) OVER () AS firstSeen,
@@ -22,11 +22,12 @@ export function getPlayerProfile(guid: string, requestedOffset = 0,
if (!player) return null; if (!player) return null;
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats; const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
if (!stats.gamesPlayed) return null; if (!stats.gamesPlayed) return null;
const historyParameters = [guid]; const historyParameters: Array<string | number> = [guid];
let historyScope = scope; let historyScope = scope;
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); } if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
if (options.map) { historyScope += ' AND m.map_key = ?'; historyParameters.push(options.map); } if (options.map) { historyScope += ' AND m.map_key = ?'; historyParameters.push(options.map); }
const matchesTotal = options.game || options.map if (options.players) { historyScope += ' AND m.start_players = ?'; historyParameters.push(options.players); }
const matchesTotal = options.game || options.map || options.players
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total ? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
: stats.gamesPlayed; : stats.gamesPlayed;
const limit = 25; const limit = 25;
+17
View File
@@ -0,0 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
let { updatedAt = null, error = '', intervalLabel = '', onRetry }: { updatedAt?: number | null; error?: string; intervalLabel?: string; onRetry?: () => void } = $props();
let now = $state(Date.now());
onMount(() => { const timer = setInterval(() => now = Date.now(), 1000); return () => clearInterval(timer); });
const age = $derived(updatedAt === null ? null : Math.max(0, Math.floor((now - updatedAt) / 1000)));
const ago = $derived(age === null ? '' : age < 5 ? 'just now' : age < 60 ? `${age} s ago` : `${Math.floor(age / 60)} min ago`);
</script>
<div class="freshness" class:failed={!!error}>
{#if error}<span>{error}</span>{#if onRetry}<button type="button" onclick={onRetry}>Retry</button>{/if}
{:else}<span>{updatedAt === null ? 'Waiting for update' : `Live · updated ${ago}`}{intervalLabel ? ` · ${intervalLabel}` : ''}</span>{/if}
</div>
<style>
.freshness { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.failed { color: var(--state-warning); }
button { background: none; border: 0; padding: var(--space-1); color: var(--accent-primary-hover); font: inherit; text-decoration: underline; cursor: pointer; }
</style>
+45 -25
View File
@@ -11,6 +11,8 @@
import { formatDuration } from '$ui/duration.js'; import { formatDuration } from '$ui/duration.js';
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 Dialog from './primitives/Dialog.svelte';
import Freshness from './Freshness.svelte';
let { server, initial }: { server?: string; initial?: MatchHistoryResponse } = $props(); let { server, initial }: { server?: string; initial?: MatchHistoryResponse } = $props();
let data = $state<MatchHistoryResponse | undefined>(untrack(() => initial)); let data = $state<MatchHistoryResponse | undefined>(untrack(() => initial));
@@ -18,6 +20,8 @@
let error = $state(''); let error = $state('');
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? '')); let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
let retry = $state(0); let retry = $state(0);
let filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
let tableRegion = $state<HTMLDivElement>(); let tableRegion = $state<HTMLDivElement>();
let pendingUrl: URL | undefined; let pendingUrl: URL | undefined;
let navigationId = 0; let navigationId = 0;
@@ -29,13 +33,14 @@
return `${endpoint}?${params}`; return `${endpoint}?${params}`;
}); });
let loadedUrl = $state(untrack(() => initial ? apiUrl : '')); let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
const filterCount = $derived([filters.game, filters.map, filters.q, filters.days, filters.status].filter(Boolean).length);
const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status)); const hasFilters = $derived(!!(filters.game || filters.map || filters.q || filters.days || filters.status));
const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }]; const sorts: Array<{ key: MatchSort; label: string }> = [{ key: 'started', label: 'Started' }, { key: 'players', label: 'Players' }, { key: 'duration', label: 'Duration' }, { key: 'round', label: 'Round' }];
$effect(() => { search = filters.q; }); $effect(() => { search = filters.q; });
$effect(() => { $effect(() => {
const report = initial; const report = initial;
if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; }); if (report) untrack(() => { data = report; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = Date.now(); });
}); });
$effect(() => { $effect(() => {
const url = apiUrl; const url = apiUrl;
@@ -49,7 +54,7 @@
if (signal.aborted || url !== apiUrl) return; if (signal.aborted || url !== apiUrl) return;
// Do not move keyboard users to another match while they are following a link. // Do not move keyboard users to another match while they are following a link.
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body; if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body;
loadedUrl = url; loading = false; error = ''; loadedUrl = url; loading = false; error = ''; checkedAt = Date.now();
} catch (cause) { } catch (cause) {
if (signal.aborted || url !== apiUrl) return; if (signal.aborted || url !== apiUrl) return;
error = cause instanceof Error ? cause.message : 'Could not load match history.'; error = cause instanceof Error ? cause.message : 'Could not load match history.';
@@ -71,27 +76,33 @@
</script> </script>
<section class="history" aria-label="Match history"> <section class="history" aria-label="Match history">
{#snippet filterFields(suffix: string)}
<div class="filters"> <div class="filters">
{#if server === undefined} {#if server === undefined}<label for={`match-game${suffix}`}>Game<select id={`match-game${suffix}`} value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>{/if}
<label>Game<select value={filters.game} onchange={event => update({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label> <label for={`match-map${suffix}`}>Map<select id={`match-map${suffix}`} value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
{/if}
<label>Map<select value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
<option value="">All maps</option> <option value="">All maps</option>
{#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if} {#if filters.map && !data?.maps.some(map => map.key === filters.map)}<option value={filters.map}>{filters.map}</option>{/if}
{#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game.toUpperCase()}` : ''}</option>{/each} {#each data?.maps ?? [] as map (map.key)}<option value={map.key}>{map.name}{!filters.game && server === undefined ? ` · ${map.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
</select></label> </select></label>
{#if server === undefined} {#if server === undefined}<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}>
<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); }}> <label for={`match-search${suffix}`}>Server<input id={`match-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Find a server…" aria-describedby={`match-search-help${suffix}`} /></label>
<label for="match-search">Instance<input id="match-search" type="search" bind:value={search} maxlength="100" placeholder="Find an instance…" aria-describedby="match-search-help" /></label> <span id={`match-search-help${suffix}`} class="sr-only">Press Enter to search by server name.</span>
<span id="match-search-help" class="sr-only">Press Enter to search by instance name.</span> </form>{/if}
</form> <label for={`match-days${suffix}`}>Date range<select id={`match-days${suffix}`} value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
<label for={`match-status${suffix}`}>Status<select id={`match-status${suffix}`} value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
{#if suffix}
<label for={`match-sort${suffix}`}>Sort by<select id={`match-sort${suffix}`} value={filters.sort} onchange={event => update({ sort: event.currentTarget.value })}>{#each sorts as item}<option value={item.key}>{item.label}</option>{/each}</select></label>
<label for={`match-order${suffix}`}>Order<select id={`match-order${suffix}`} value={filters.direction} onchange={event => update({ direction: event.currentTarget.value })}><option value="desc">Highest or newest first</option><option value="asc">Lowest or oldest first</option></select></label>
{/if} {/if}
<label>Date range<select value={filters.days} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All time</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></select></label>
<details class="more-filters"><summary>More filters{filters.status ? ' (1)' : ''}</summary>
<label>Status<select value={filters.status} onchange={event => update({ status: event.currentTarget.value })}><option value="">All statuses</option><option value="completed">Completed</option><option value="active">In progress</option><option value="interrupted">Interrupted</option></select></label>
</details>
</div> </div>
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Matches started in the last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}</div> {/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<div class="mobile-filters"><span>{data?.total ?? '—'} matches{filters.map ? ` · ${data?.maps.find(map => map.key === filters.map)?.name ?? 'Selected map'}` : ''}</span><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>
<Dialog bind:open={filtersOpen} title="Match filters" description="Choose the matches to show." drawer>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={reset}>Clear filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
</Dialog>
<div class="context"><span>Times in {$timeZone}{filters.days ? ` · Last ${filters.days} days` : ''}</span>{#if hasFilters}<button class="text-button" onclick={reset}>Clear filters</button>{/if}{#if !error}<Freshness updatedAt={checkedAt} intervalLabel="Updates every 10 seconds" />{/if}</div>
{#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if} {#if error}<div class="error" role="alert">{error} <Button size="sm" onclick={() => retry++}>Retry</Button></div>{/if}
{#if loading}<EmptyState title="Loading matches…" /> {#if loading}<EmptyState title="Loading matches…" />
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." /> {:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." />
@@ -112,11 +123,11 @@
<tr> <tr>
<td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{matchStartedLabel(match.startedAt, $timeZone)}</time></td> <td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{matchStartedLabel(match.startedAt, $timeZone)}</time></td>
<td class="match"> <td class="match">
{#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map}</a> {#if match.status === 'completed'}<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}>{match.map} · Match #{match.id}</a>
{:else}<span class="match-name">{match.map}</span>{/if} {:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"><time datetime={match.startedAt}>{matchStartedLabel(match.startedAt, $timeZone)}</time> · {match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}{match.durationMs !== null ? ` · ${formatDuration(match.durationMs)}` : ''}</small>
{#if server === undefined}<div class="instance"> {#if server === undefined}<div class="instance">
{#if !match.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(match.server)}?tab=matches`, $page.url)} title={match.server}>{matchInstanceLabel(match)}</a> <span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
{:else}<span title={match.server}>{matchInstanceLabel(match)}</span><span> · Historical instance</span>{/if}
</div>{/if} </div>{/if}
</td> </td>
<td><span class="status" class:active={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td> <td><span class="status" class:active={match.status === 'active'} class:interrupted={match.status === 'interrupted'}>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</span></td>
@@ -143,12 +154,10 @@
label, form { flex: 1; min-width: 145px; } label, form { flex: 1; min-width: 145px; }
label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); } label { display: flex; flex-direction: column; gap: 6px; color: var(--text-secondary); font-size: var(--text-xs); }
input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); } input, select { width: 100%; min-height: 40px; padding: 8px 10px; border: 1px solid var(--border-control); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
.more-filters { padding-top: 25px; font-size: var(--text-sm); }
.more-filters label { margin-top: 10px; }
summary { cursor: pointer; color: var(--text-secondary); } summary { cursor: pointer; color: var(--text-secondary); }
.context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); } .context, .pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); font-size: var(--text-xs); color: var(--text-secondary); }
.pagination > div { display: flex; gap: var(--space-2); } .pagination > div { display: flex; gap: var(--space-2); }
.table-scroll { overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); } .table-scroll { position: relative; overflow-x: auto; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); } .table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); } table { width: 100%; border-collapse: collapse; background: var(--surface); font-size: var(--text-sm); }
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; } th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
@@ -165,7 +174,6 @@
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; } a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); } .instance, small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-secondary); }
.instance a { color: inherit; text-decoration: none; }
.status { color: var(--text-secondary); } .status { color: var(--text-secondary); }
.status.active { color: var(--accent-primary-hover); } .status.active { color: var(--accent-primary-hover); }
.status.interrupted { color: var(--state-warning); } .status.interrupted { color: var(--state-warning); }
@@ -173,5 +181,17 @@
.about p { max-width: 90ch; line-height: 1.6; } .about p { max-width: 90ch; line-height: 1.6; }
.error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); } .error { display: flex; align-items: center; gap: var(--space-3); color: var(--state-warning); font-size: var(--text-sm); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 640px) { .filters > label, .filters > form { flex-basis: 45%; min-width: 120px; } .more-filters { padding-top: 0; width: 100%; } th, td { padding: 10px 12px; } } .mobile-filters, .mobile-match-context { display: none; }
@media (max-width: 640px) {
.desktop-filters { display: none; }
.mobile-match-context { display: block; }
.mobile-filters { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); font-size: var(--text-sm); }
.filters { display: grid; grid-template-columns: minmax(0, 1fr); }
.filters > label, .filters > form { min-width: 0; }
.context { gap: var(--space-1) var(--space-2); }
th, td { padding: 10px 12px; }
th:nth-child(1), td:nth-child(1) { display: none; }
th:nth-child(3), td:nth-child(3), th:nth-child(5), td:nth-child(5) { display: none; }
.match { min-width: 130px; }
}
</style> </style>
+15 -54
View File
@@ -1,66 +1,27 @@
<script lang="ts"> <script lang="ts">
import { playerStatMetrics, type PlayerMapRecord, type PersonalBestMetric, type LeaderboardMetric } from '$lib/leaderboard.js'; import { type PlayerMapRecord, type LeaderboardMetric } from '$lib/leaderboard.js';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import RecordSource from '$ui/components/RecordSource.svelte'; let { maps, metric = 'highestRound', returnTo }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric; returnTo?: string } = $props();
let { maps, metric = 'highestRound' }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric } = $props();
const number = new Intl.NumberFormat('en-US'); const number = new Intl.NumberFormat('en-US');
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
const otherMetrics = playerStatMetrics.filter(metric => metric.key !== 'highestRound' && metric.key !== 'kills');
const recordMetrics = ['highestRound', 'bestKills', 'bestScore'] as const satisfies readonly PersonalBestMetric[];
</script> </script>
<div class="map-cards"> <div class="map-cards">
{#each maps as map (`${map.game}:${map.key}`)} {#each maps as map (`${map.game}:${map.key}`)}
<article class="map-card"> <article>
<header> <header><h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3><span>{map.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · {number.format(map.gamesPlayed)} {map.gamesPlayed === 1 ? 'game' : 'games'}</span></header>
<p class="game">{gameName(map.game)}</p> <dl>{#each records as record}<div><dt>{record.label}</dt><dd><a href={contextHref(`/matches/${map.records[record.key].matchId}`, returnTo ?? "/leaderboard")} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></dd></div>{/each}</dl>
<h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3> <p>{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed rounds</p>
<p class="last-played">Last played <time datetime={map.lastSeen}>{$timeFormat.dateTime(map.lastSeen)}</time></p>
</header>
<dl class="primary-stats">
<div><dt>Highest round</dt><dd class="best">{number.format(map.highestRound)}</dd></div>
<div><dt>Total kills</dt><dd>{number.format(map.kills)}</dd></div>
</dl>
<details class="more-stats">
<summary aria-label={`All stats and record runs for ${map.name}, ${gameName(map.game)}`}>All stats and record runs</summary>
<dl class="additional-stats">
{#each otherMetrics as metric (metric.key)}
<div><dt>{metric.label}</dt><dd>{number.format(map[metric.key])}</dd></div>
{/each}
</dl>
<div class="record-runs">
{#each recordMetrics as key (key)}
<div class="record-run">
<h4>{playerStatMetrics.find(metric => metric.key === key)!.label} · {number.format(map[key])}</h4>
<RecordSource evidence={map.records[key]} />
</div>
{/each}
</div>
</details>
</article> </article>
{/each} {/each}
</div> </div>
<style> <style>
.map-cards { min-width: 0; } article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
.map-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; } h3, p, dl, dd { margin: 0; }
h3, h4, p, dl, dd { margin: 0; } h3 { font-size: var(--text-md); }
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; } header span, p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
h4 { font-size: var(--text-sm); line-height: 1.5; } dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin: var(--space-3) 0; }
dd { font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; margin-top: var(--space-1); }
dd a { display: inline-flex; align-items: center; min-height: 44px; }
a { color: var(--accent-primary-hover); } a { color: var(--accent-primary-hover); }
.game, .last-played { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
.last-played { margin-top: var(--space-2); }
.primary-stats, .additional-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.primary-stats { margin-top: var(--space-4); }
dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
dd { margin-top: var(--space-1); font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
.primary-stats dd { font-size: var(--text-xl); }
.best { color: var(--accent-primary-hover); }
.more-stats { margin-top: var(--space-2); }
summary { min-height: 44px; padding: var(--space-3) 0; box-sizing: border-box; color: var(--accent-primary-hover); font-size: var(--text-sm); line-height: 1.5; cursor: pointer; }
.record-runs { margin-top: var(--space-4); }
.record-run { padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
.record-run + .record-run { margin-top: var(--space-4); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
</style> </style>
+15 -52
View File
@@ -1,64 +1,27 @@
<script lang="ts"> <script lang="ts">
import { playerStatMetrics, type PlayerMatch } from '$lib/leaderboard.js'; import type { PlayerMatch } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '$ui/components/primitives/Badge.svelte'; import { serverTitle } from '$ui/server-labels.js';
import { contextHref } from '$lib/navigation-context.js';
let { matches }: { matches: PlayerMatch[] } = $props(); let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props();
const number = new Intl.NumberFormat('en-US'); const number = new Intl.NumberFormat('en-US');
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
const otherMetrics = ['roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const;
</script> </script>
<div class="match-cards"> <div class="match-cards">
{#each matches as match (match.id)} {#each matches as match (match.id)}
<article class="match-card"> <article>
<header> <h3>{#if match.status === 'completed'}<a href={contextHref(`/matches/${match.id}`, returnTo ?? "/leaderboard")}>{match.mapName} · Match #{match.id}</a>{:else}{match.mapName} · Match #{match.id}{/if}</h3>
<p class="game">{gameName(match.game)}</p> <p>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</p>
<h3>{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${$timeFormat.dateTime(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}</h3> <p><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time> · {match.completed ? 'Completion credited' : 'No completion credit'}</p>
<p class="server">{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</p> <dl><div><dt>Round</dt><dd>{number.format(match.highestRound)}</dd></div><div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div><div><dt>Revives</dt><dd>{number.format(match.revives)}</dd></div></dl>
</header>
<dl class="match-info">
<div><dt>First observed</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
<div><dt>Match status</dt><dd><span class="status">{match.status}</span> · {match.completed ? 'Completion credited' : 'No completion credit'}</dd></div>
</dl>
<dl class="primary-stats">
<div><dt>Highest round</dt><dd class="best">{number.format(match.highestRound)}</dd></div>
<div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div>
</dl>
<details class="more-stats">
<summary aria-label={`More match stats for ${match.mapName} from ${$timeFormat.dateTime(match.startedAt)}`}>More match stats</summary>
<dl class="additional-stats">
{#each otherMetrics as key (key)}
<div><dt>{playerStatMetrics.find(metric => metric.key === key)!.label}</dt><dd>{number.format(match[key])}</dd></div>
{/each}
</dl>
<p class="observation">Last observed <time datetime={match.lastSeen}>{$timeFormat.dateTime(match.lastSeen)}</time></p>
<p class="match-id">Match #{match.id}</p>
</details>
</article> </article>
{/each} {/each}
</div> </div>
<style> <style>
.match-cards { min-width: 0; } article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
.match-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
h3, p, dl, dd { margin: 0; } h3, p, dl, dd { margin: 0; }
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; } h3 { font-size: var(--text-sm); line-height: var(--leading-body); }
a { color: var(--accent-primary-hover); } h3 a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent-primary-hover); }
.game, .server, .observation, .match-id { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; } p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.server { margin-top: var(--space-2); } dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-2); margin-top: var(--space-3); }
.match-info { display: grid; gap: var(--space-2); margin-top: var(--space-3); } dd { margin-top: var(--space-1); font-family: var(--font-numeric); font-size: var(--text-lg); font-variant-numeric: tabular-nums; }
dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
dd { margin-top: var(--space-1); font-size: var(--text-sm); line-height: 1.5; }
.status { text-transform: capitalize; }
.primary-stats, .additional-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.primary-stats { margin-top: var(--space-4); }
.primary-stats dd, .additional-stats dd { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.primary-stats dd { font-size: var(--text-xl); }
.best { color: var(--accent-primary-hover); }
.more-stats { margin-top: var(--space-2); }
summary { min-height: 44px; padding: var(--space-3) 0; box-sizing: border-box; color: var(--accent-primary-hover); font-size: var(--text-sm); line-height: 1.5; cursor: pointer; }
.observation { margin-top: var(--space-4); }
.match-id { margin-top: var(--space-1); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
</style> </style>
+3 -29
View File
@@ -2,36 +2,10 @@
import type { RecordEvidence } from '$lib/leaderboard.js'; import type { RecordEvidence } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
let { evidence, returnTo, metric = 'Record' }: { evidence: RecordEvidence; returnTo?: string; metric?: string; disclosure?: boolean } = $props();
let { evidence, returnTo, disclosure = true }: { evidence: RecordEvidence; returnTo?: string; disclosure?: boolean } = $props();
const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II');
</script> </script>
<span class="record-source"><a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`} aria-label={`${metric}: ${evidence.mapName}, Match #${evidence.matchId}`} title={evidence.endedAt ? `Completed ${$timeFormat.dateTime(evidence.endedAt)}` : 'Completed match; end time unknown'}>Match #{evidence.matchId} · {evidence.mapName}</a></span>
<div class="record-source">
<a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`}>{evidence.mapName} · {gameName}</a>
<span>{#if evidence.endedAt}Completed <time datetime={evidence.endedAt}>{$timeFormat.dateTime(evidence.endedAt)}</time>{:else}Completed · End time unknown{/if}</span>
{#snippet runDetails()}
<dl>
<div><dt>Instance</dt><dd>{evidence.server}</dd></div>
<div><dt>{evidence.startObserved ? 'Match started' : 'Match first observed'}</dt><dd><time datetime={evidence.startedAt}>{$timeFormat.dateTime(evidence.startedAt)}</time></dd></div>
<div><dt>Player participation</dt><dd>{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}</dd></div>
</dl>
<p>Recorded presence does not establish continuous survival.</p>
{/snippet}
{#if disclosure}<details><summary>Run details</summary>{@render runDetails()}</details>
{:else}{@render runDetails()}{/if}
</div>
<style> <style>
.record-source { margin-top: var(--space-2); max-width: 24rem; white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; color: var(--text-secondary); } .record-source { display: block; margin-top: var(--space-1); white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: var(--leading-body); }
a { color: var(--accent-primary-hover); } a { color: var(--accent-primary-hover); }
span { display: block; margin-top: 3px; color: var(--text-tertiary); }
details { margin-top: var(--space-1); }
summary { cursor: pointer; }
dl { margin: var(--space-2) 0; }
dl > div + div { margin-top: var(--space-2); }
dt { color: var(--text-tertiary); }
dd { margin: 0; }
p { margin: var(--space-2) 0 0; color: var(--text-tertiary); }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
</style> </style>
+5 -1
View File
@@ -1,6 +1,7 @@
import { leaderboardReport } from '$lib/server/reports/index.js'; import { leaderboardReport } from '$lib/server/reports/index.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { isLeaderboardMetric, MAX_LEADERBOARD_OFFSET } from '$lib/server/fleet/leaderboard.js'; import { isLeaderboardMetric, MAX_LEADERBOARD_OFFSET } from '$lib/server/fleet/leaderboard.js';
import type { LeaderboardPlayerCount } from '$lib/leaderboard.js';
export async function GET({ url }: { url: URL }) { export async function GET({ url }: { url: URL }) {
const params = url.searchParams; const params = url.searchParams;
@@ -8,6 +9,7 @@ export async function GET({ url }: { url: URL }) {
const map = params.get('map'); const map = params.get('map');
const metric = params.get('metric'); const metric = params.get('metric');
const q = params.get('q'); const q = params.get('q');
const players = params.get('players');
const rawLimit = params.get('limit'); const rawLimit = params.get('limit');
const rawOffset = params.get('offset'); const rawOffset = params.get('offset');
const integer = (value: string | null, fallback: number) => value === null ? fallback const integer = (value: string | null, fallback: number) => value === null ? fallback
@@ -17,11 +19,13 @@ export async function GET({ url }: { url: URL }) {
if ((game !== null && game !== 't5' && game !== 't6') if ((game !== null && game !== 't5' && game !== 't6')
|| (map !== null && map.length > 1024) || (q !== null && q.length > 100) || (map !== null && map.length > 1024) || (q !== null && q.length > 100)
|| (metric !== null && !isLeaderboardMetric(metric)) || (metric !== null && !isLeaderboardMetric(metric))
|| (players !== null && !/^[1-4]$/.test(players))
|| !Number.isFinite(limit) || limit < 1 || limit > 100 || !Number.isFinite(limit) || limit < 1 || limit > 100
|| !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) { || !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) {
return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 }); return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, players 1–4, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 });
} }
return json(await leaderboardReport({ game: game ?? undefined, map: map || undefined, return json(await leaderboardReport({ game: game ?? undefined, map: map || undefined,
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined, metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
players: players === null ? undefined : Number(players) as LeaderboardPlayerCount,
q: q ?? undefined, limit, offset })); q: q ?? undefined, limit, offset }));
} }
+19 -2
View File
@@ -1,9 +1,26 @@
import { GET } from '../api/leaderboard/+server.js'; import { GET } from '../api/leaderboard/+server.js';
import { error } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import type { LeaderboardResponse } from '$lib/leaderboard.js'; import type { LeaderboardPlayerCount, LeaderboardResponse } from '$lib/leaderboard.js';
import { leaderboardPageSize } from '$lib/leaderboard-updates.js'; import { leaderboardPageSize } from '$lib/leaderboard-updates.js';
import { defaultLeaderboardMap } from '$lib/server/fleet/leaderboard.js';
export async function load({ url }: { url: URL }) { export async function load({ url }: { url: URL }) {
// A present empty map is the explicit All maps view. The API remains
// unfiltered by default for callers such as global player search.
if (!url.searchParams.has('map')) {
const game = url.searchParams.get('game');
const players = url.searchParams.get('players');
if (game !== null && game !== 't5' && game !== 't6' || players !== null && !/^[1-4]$/.test(players)) {
error(400, 'Use game t5 or t6 and players 1–4.');
}
const map = defaultLeaderboardMap({ game: game ?? undefined, players: players === null ? undefined : Number(players) as LeaderboardPlayerCount });
if (map) {
const destination = new URL(url);
destination.searchParams.set('map', map);
destination.searchParams.delete('offset');
redirect(307, destination.pathname + destination.search);
}
}
const reportUrl = new URL(url); const reportUrl = new URL(url);
reportUrl.searchParams.set('limit', String(leaderboardPageSize)); reportUrl.searchParams.set('limit', String(leaderboardPageSize));
const response = await GET({ url: reportUrl }); const response = await GET({ url: reportUrl });
+89 -82
View File
@@ -12,15 +12,17 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import CopyValue from '$ui/components/CopyValue.svelte'; import Dialog from '$ui/components/primitives/Dialog.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RecordSource from '$ui/components/RecordSource.svelte'; import RecordSource from '$ui/components/RecordSource.svelte';
const number = new Intl.NumberFormat('en-US'); const number = new Intl.NumberFormat('en-US');
const pageSize = leaderboardPageSize; const pageSize = leaderboardPageSize;
const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }]; const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }];
const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed']; const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'gamesPlayed'];
const squadSizes = [{ key: '1', label: 'Solo' }, { key: '2', label: 'Duo' }, { key: '3', label: 'Trio' }, { key: '4', label: 'Four players' }];
const descriptions: Record<PlayerStatMetric, string> = { const descriptions: Record<PlayerStatMetric, string> = {
highestRound: 'The highest round reached in a recorded game.', highestRound: 'The highest recorded round, including players who joined mid-game.',
kills: 'Cumulative kills across recorded games.', kills: 'Cumulative kills across recorded games.',
roundsSurvived: 'Round progression during observed participation, added across games.', roundsSurvived: 'Round progression during observed participation, added across games.',
headshots: 'Cumulative headshots across recorded games.', headshots: 'Cumulative headshots across recorded games.',
@@ -41,9 +43,10 @@
let loading = $state(false); let loading = $state(false);
let error = $state(''); let error = $state('');
let search = $state(untrack(() => ($page.url.searchParams.get('q') ?? '').trim().slice(0, 100))); let search = $state(untrack(() => ($page.url.searchParams.get('q') ?? '').trim().slice(0, 100)));
let checkedAt = $state<Date | null>(null); let checkedAt = $state<Date | null>(untrack(() => initial.leaderboard ? new Date() : null));
let pendingUpdate = $state<LeaderboardResponse | null>(null); let pendingUpdate = $state<LeaderboardResponse | null>(null);
let expandedPlayers = $state<string[]>([]); let filtersOpen = $state(false);
let showAllStats = $state(false);
let tableRegion = $state<HTMLDivElement>(); let tableRegion = $state<HTMLDivElement>();
let controller: AbortController | undefined; let controller: AbortController | undefined;
let requestId = 0; let requestId = 0;
@@ -53,6 +56,8 @@
const game = $derived(games.some(item => item.key === $page.url.searchParams.get('game')) ? $page.url.searchParams.get('game')! : ''); const game = $derived(games.some(item => item.key === $page.url.searchParams.get('game')) ? $page.url.searchParams.get('game')! : '');
const map = $derived($page.url.searchParams.get('map') ?? ''); const map = $derived($page.url.searchParams.get('map') ?? '');
const players = $derived(squadSizes.some(size => size.key === $page.url.searchParams.get('players')) ? $page.url.searchParams.get('players')! : '');
const playersLabel = $derived(squadSizes.find(size => size.key === players)?.label ?? 'All squad sizes');
const query = $derived(($page.url.searchParams.get('q') ?? '').trim().slice(0, 100)); const query = $derived(($page.url.searchParams.get('q') ?? '').trim().slice(0, 100));
const metric = $derived(leaderboardMetrics.find(item => item.key === $page.url.searchParams.get('metric'))?.key ?? 'highestRound'); const metric = $derived(leaderboardMetrics.find(item => item.key === $page.url.searchParams.get('metric'))?.key ?? 'highestRound');
const offset = $derived(Math.max(0, Math.min(1_000_000, Math.floor(Number($page.url.searchParams.get('offset')) || 0)))); const offset = $derived(Math.max(0, Math.min(1_000_000, Math.floor(Number($page.url.searchParams.get('offset')) || 0))));
@@ -60,11 +65,13 @@
const mapName = $derived(maps.find(item => item.key === map)?.name); const mapName = $derived(maps.find(item => item.key === map)?.name);
const scope = $derived(mapName ?? (game ? games.find(item => item.key === game)!.label : 'All games & maps')); const scope = $derived(mapName ?? (game ? games.find(item => item.key === game)!.label : 'All games & maps'));
const metricLabel = $derived(leaderboardMetrics.find(item => item.key === metric)!.label); const metricLabel = $derived(leaderboardMetrics.find(item => item.key === metric)!.label);
const columns = $derived([metric, ...defaultColumns.filter(key => key !== metric)]); const columns = $derived([metric, ...(showAllStats ? playerStatMetrics.map(item => item.key) : defaultColumns).filter(key => key !== metric)]);
const filterCount = $derived([game, map, players, query].filter(Boolean).length);
const apiUrl = $derived.by(() => { const apiUrl = $derived.by(() => {
const params = new URLSearchParams({ metric, limit: String(pageSize), offset: String(offset) }); const params = new URLSearchParams({ metric, limit: String(pageSize), offset: String(offset) });
if (game) params.set('game', game); if (game) params.set('game', game);
if (map) params.set('map', map); params.set('map', map);
if (players) params.set('players', players);
if (query) params.set('q', query); if (query) params.set('q', query);
return `/api/leaderboard?${params}`; return `/api/leaderboard?${params}`;
}); });
@@ -76,8 +83,8 @@
const report = initial.leaderboard; const report = initial.leaderboard;
untrack(() => { untrack(() => {
++requestId; controller?.abort(); data = report; maps = report.maps; ++requestId; controller?.abort(); data = report; maps = report.maps;
loadedUrl = apiUrl; loading = false; error = ''; checkedAt = null; loadedUrl = apiUrl; loading = false; error = ''; checkedAt = new Date();
pendingUpdate = null; expandedPlayers = []; updates.reset(apiUrl, report); pendingUpdate = null; updates.reset(apiUrl, report);
}); });
}); });
@@ -107,8 +114,7 @@
if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`); if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`);
const result: LeaderboardResponse = await response.json(); const result: LeaderboardResponse = await response.json();
if (id !== requestId || url !== apiUrl || changingFilters || pendingUrl) return; if (id !== requestId || url !== apiUrl || changingFilters || pendingUrl) return;
const inspecting = expandedPlayers.length > 0 || !!tableRegion?.contains(document.activeElement) const inspecting = !!tableRegion?.contains(document.activeElement);
|| !!tableRegion?.querySelector('details[open]');
const next = updates.receive(url, result, inspecting); const next = updates.receive(url, result, inspecting);
if (!next) return; if (!next) return;
data = next.current; data = next.current;
@@ -126,25 +132,18 @@
} }
function applyUpdate() { function applyUpdate() {
expandedPlayers = [];
tableRegion?.querySelectorAll('details[open]').forEach(detail => detail.removeAttribute('open'));
data = updates.apply(); data = updates.apply();
maps = data.maps; maps = data.maps;
pendingUpdate = null; pendingUpdate = null;
} }
function togglePlayer(guid: string) { function updateFilters(changes: Record<string, string | null>) {
expandedPlayers = expandedPlayers.includes(guid)
? expandedPlayers.filter(value => value !== guid) : [...expandedPlayers, guid];
}
function updateFilters(changes: Record<string, string>) {
++requestId; controller?.abort(); loading = false; ++requestId; controller?.abort(); loading = false;
// Keep rapid changes together while the preceding navigation settles. // Keep rapid changes together while the preceding navigation settles.
const url = new URL(pendingUrl ?? $page.url); const url = new URL(pendingUrl ?? $page.url);
url.searchParams.delete('offset'); url.searchParams.delete('offset');
for (const [key, value] of Object.entries(changes)) { for (const [key, value] of Object.entries(changes)) {
if (value) url.searchParams.set(key, value); if (value || key === 'map' && value !== null) url.searchParams.set(key, value);
else url.searchParams.delete(key); else url.searchParams.delete(key);
} }
pendingUrl = url; pendingUrl = url;
@@ -156,7 +155,7 @@
function clearFilters() { function clearFilters() {
search = ''; search = '';
updateFilters({ game: '', map: '', q: '' }); updateFilters({ game: '', map: null, players: '', q: '' });
} }
</script> </script>
@@ -166,40 +165,49 @@
</svelte:head> </svelte:head>
<section class="page"> <section class="page">
<SectionHeader title="Leaderboard" subtitle="All-time player records."> <SectionHeader title="Leaderboard" subtitle="Records by map and starting squad.">
{#snippet actions()} {#snippet actions()}<div class="mobile-filter-trigger"><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>{/snippet}
<Button size="sm" loading={loading} disabled={changingFilters} onclick={() => void refresh(apiUrl)}>Refresh</Button>
{/snippet}
</SectionHeader> </SectionHeader>
{#snippet filterFields(suffix: string)}
<div class="filters"> <div class="filters">
<div class="field"><label for="leaderboard-game">Game</label> <div class="field"><label for={`leaderboard-game${suffix}`}>Game</label>
<select id="leaderboard-game" value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: '' })}> <select id={`leaderboard-game${suffix}`} value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: null })}>
<option value="">All games</option> <option value="">All games</option>{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select> </select>
</div> </div>
<div class="field"><label for="leaderboard-map">Map</label> <div class="field"><label for={`leaderboard-map${suffix}`}>Map</label>
<select id="leaderboard-map" value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}> <select id={`leaderboard-map${suffix}`} value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
<option value="">All maps</option> <option value="">All maps</option>
{#if map && !filteredMaps.some(item => item.key === map)}<option value={map}>{map}</option>{/if} {#if map && !filteredMaps.some(item => item.key === map)}<option value={map}>{map}</option>{/if}
{#each filteredMaps as item (item.key)}<option value={item.key}>{item.name}{!game ? ` · ${item.game.toUpperCase()}` : ''}</option>{/each} {#each filteredMaps as item (item.key)}<option value={item.key}>{item.name}{!game ? ` · ${item.game === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
</select> </select>
</div> </div>
<div class="field"><label for="leaderboard-metric">Rank by</label> <div class="field"><label for={`leaderboard-players${suffix}`}>Players</label>
<select id="leaderboard-metric" value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}> <select id={`leaderboard-players${suffix}`} value={players} onchange={event => updateFilters({ players: event.currentTarget.value })} aria-describedby={`players-help${suffix}`}>
<option value="">All squad sizes</option>{#each squadSizes as size}<option value={size.key}>{size.label}</option>{/each}
</select><span id={`players-help${suffix}`} class="sr-only">Players recorded at the start of each match. Matches with unknown starting counts appear only under All squad sizes.</span>
</div>
<div class="field"><label for={`leaderboard-metric${suffix}`}>Rank by</label>
<select id={`leaderboard-metric${suffix}`} value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}>
{#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each} {#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select> </select>
</div> </div>
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); }} role="search"> <form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); if (suffix) filtersOpen = false; }} role="search">
<label for="leaderboard-search">Find a player</label> <label for={`leaderboard-search${suffix}`}>Find a player</label>
<input id="leaderboard-search" type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" aria-describedby="search-help" /><span id="search-help" class="sr-only">Press Enter to search. Player ranks are preserved.</span> <input id={`leaderboard-search${suffix}`} type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" aria-describedby={`search-help${suffix}`} /><span id={`search-help${suffix}`} class="sr-only">Press Enter to search. Player ranks are preserved.</span>
</form> </form>
</div> </div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<Dialog bind:open={filtersOpen} title="Leaderboard filters" description="Choose a map, starting squad size, and ranking." drawer>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={clearFilters}>Reset filters</Button><Button variant="primary" onclick={() => filtersOpen = false}>Show results</Button>{/snippet}
</Dialog>
<div class="scope-row"> <div class="scope-row">
<div><span class="scope">{scope}</span><span class="all-time">All time</span></div> <div><span class="scope">{scope}</span><span class="all-time">{playersLabel}</span><span class="all-time">All time</span></div>
{#if game || map || query}<button class="text-button" onclick={clearFilters}>Clear filters</button>{/if} {#if game || players || query}<button class="text-button" onclick={clearFilters}>Reset filters</button>{/if}
</div> </div>
{#if error} {#if error}
@@ -215,13 +223,13 @@
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Unique players</small></div> <div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Unique players</small></div>
</section> </section>
<section class="rankings" aria-labelledby="rankings-title" aria-busy={loading || changingFilters}> <section class="rankings" class:show-all-stats={showAllStats} aria-labelledby="rankings-title" aria-busy={loading || changingFilters}>
<div class="rankings-header"> <div class="rankings-header">
<div><h2 id="rankings-title">{metricLabel}</h2> <div><h2 id="rankings-title">{metricLabel}</h2>
{#if isEfficiencyMetric(metric)}<p>{efficiencyRequirement(metric)} within the selected game and map.</p>{/if} {#if isEfficiencyMetric(metric)}<p>{efficiencyRequirement(metric)} within the selected game and map.</p>{/if}
<details class="metric-help"><summary>About this ranking</summary><p>{description(metric)} Equal values share a rank.</p></details> <p class="metric-description">{description(metric)} Equal values share a rank.</p>
</div> </div>
<div class="refresh-status" role="status">{changingFilters ? 'Updating filters…' : loading ? 'Checking…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : 'Checks every 15 seconds'}</div> <div class="ranking-controls"><label class="all-stats"><input type="checkbox" bind:checked={showAllStats} /> Show all stats</label>{#if !error}<Freshness updatedAt={checkedAt?.getTime() ?? null} intervalLabel="Updates every 15 seconds" />{/if}</div>
</div> </div>
{#if pendingUpdate}<div class="pending-update" role="status"><button class="text-button" onclick={applyUpdate} disabled={changingFilters}>Updated rankings available — apply update</button></div>{/if} {#if pendingUpdate}<div class="pending-update" role="status"><button class="text-button" onclick={applyUpdate} disabled={changingFilters}>Updated rankings available — apply update</button></div>{/if}
{#if data.entries.length} {#if data.entries.length}
@@ -244,7 +252,7 @@
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td> <td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
<th scope="row" class="player"> <th scope="row" class="player">
<a class="player-name" href={playerContextHref(entry.guid, `${$page.url.pathname}${$page.url.search}`)}>{entry.name}</a> <a class="player-name" href={playerContextHref(entry.guid, `${$page.url.pathname}${$page.url.search}`)}>{entry.name}</a>
<button class="text-button" aria-expanded={expandedPlayers.includes(entry.guid)} aria-controls={`player-details-${entry.guid}`} onclick={() => togglePlayer(entry.guid)}>{expandedPlayers.includes(entry.guid) ? 'Hide details' : 'Details'}<span class="sr-only"> for {entry.name}</span></button>
</th> </th>
{#each columns as key (key)} {#each columns as key (key)}
<td class="stat" class:selected={key === metric}> <td class="stat" class:selected={key === metric}>
@@ -252,24 +260,12 @@
{#if isEfficiencyMetric(key)} {#if isEfficiencyMetric(key)}
<small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small> <small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small>
{/if} {/if}
{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'} {#if key === metric && (key === 'highestRound' || key === 'bestKills' || key === 'bestScore')}
<small class="record-map">{entry.records[key].mapName}</small> <RecordSource evidence={entry.records[key]} metric={metricLabel} returnTo={`${$page.url.pathname}${$page.url.search}`} />
<details class="record-details">
<summary>Record details<span class="sr-only"> for {entry.name}</span></summary>
<RecordSource evidence={entry.records[key]} disclosure={false} returnTo={`${$page.url.pathname}${$page.url.search}`} />
</details>
{/if} {/if}
</td> </td>
{/each} {/each}
</tr> </tr>
{#if expandedPlayers.includes(entry.guid)}
<tr class="player-details" id={`player-details-${entry.guid}`}><td colspan={columns.length + 2}>
<div class="identity">GUID <CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
<dl>{#each playerStatMetrics.filter(item => !columns.includes(item.key)) as stat (stat.key)}
<div><dt>{stat.label}</dt><dd>{formatValue(entry, stat.key)}</dd></div>
{/each}</dl>
</td></tr>
{/if}
{/each} {/each}
</tbody> </tbody>
</table> </table>
@@ -282,9 +278,9 @@
<EmptyState spacious title="No qualifying players found" description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}> <EmptyState spacious title="No qualifying players found" description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}>
{#snippet actions()}<Button size="sm" onclick={() => updateFilters({ metric: 'highestRound' })}>Rank by highest round</Button>{/snippet} {#snippet actions()}<Button size="sm" onclick={() => updateFilters({ metric: 'highestRound' })}>Rank by highest round</Button>{/snippet}
</EmptyState> </EmptyState>
{:else if query || game || map || offset} {:else if query || game || map || players || offset}
<EmptyState spacious title="No players found" description="Try a different game, map, or player name."> <EmptyState spacious title="No players found" description="Try a different game, map, starting squad size, or player name.">
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet} {#snippet actions()}<Button size="sm" onclick={clearFilters}>Reset filters</Button>{/snippet}
</EmptyState> </EmptyState>
{:else} {:else}
<EmptyState spacious title="Your first records start here" description="Player stats appear after a game ends, once the player has been observed at round 2 or later." /> <EmptyState spacious title="Your first records start here" description="Player stats appear after a game ends, once the player has been observed at round 2 or later." />
@@ -294,11 +290,12 @@
<div class="loading" role="status">Loading player records…</div> <div class="loading" role="status">Loading player records…</div>
{/if} {/if}
{#if data}<details class="mobile-totals"><summary>Selection totals</summary><dl><div><dt>Highest round</dt><dd>{number.format(data.summary.highestRound)}</dd></div><div><dt>Total kills</dt><dd>{number.format(data.summary.kills)}</dd></div><div><dt>Total rounds</dt><dd>{number.format(data.summary.roundsSurvived)}</dd></div><div><dt>Games tracked</dt><dd>{number.format(data.summary.matches)}</dd></div><div><dt>Players</dt><dd>{number.format(data.summary.players)}</dd></div></dl></details>{/if}
<details class="tracking-notes"> <details class="tracking-notes">
<summary>How these records work</summary> <summary>How these records work</summary>
<div> <div>
<p>Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name changes keep a player's records together. The latest reported name across both games appears here and on the player's profile, regardless of filters.</p> <p>Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name changes keep a player's records together. The latest reported name across both games appears here and on the player's profile, regardless of filters.</p>
<p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game and map filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.</p> <p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game, map, and starting squad-size filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once. The top stats cover all recorded players in the selected game and map; player search and ranking qualification do not change those totals.</p>
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p> <p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p> <p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p> <p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
@@ -308,24 +305,18 @@
<style> <style>
.overview { display: grid; grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; } .overview { display: grid; grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.overview > div { display: flex; flex-direction: column; justify-content: center; gap: var(--space-2); padding: 24px; border-right: 1px solid var(--border-subtle); min-width: 0; } .overview > div { display: flex; flex-direction: column; justify-content: center; gap: var(--space-1); padding: var(--space-4); border-right: 1px solid var(--border-subtle); min-width: 0; }
.overview > div:last-child { border-right: 0; } .overview > div:last-child { border-right: 0; }
.overview span, .overview small { color: var(--text-secondary); font-size: var(--text-xs); } .overview span, .overview small { color: var(--text-secondary); font-size: var(--text-xs); }
.overview strong { font-size: clamp(28px, 3vw, 40px); line-height: 1.15; font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; letter-spacing: -.035em; overflow-wrap: anywhere; } .overview strong { font-size: var(--text-xl); line-height: 1.15; font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; letter-spacing: -.035em; overflow-wrap: anywhere; }
.overview .record { background: linear-gradient(125deg, color-mix(in srgb, var(--accent-primary) 16%, transparent), transparent); } .overview .record { background: var(--surface-raised); }
.record strong { color: var(--accent-primary-hover); font-size: clamp(36px, 4vw, 52px); } .record strong { color: var(--text-primary); font-size: var(--text-2xl); }
.metric-help, .record-details { color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); margin-top: 4px; white-space: normal; }
summary { cursor: pointer; } summary { cursor: pointer; }
.metric-help p { margin-top: 8px; }
.record-map { display: block; margin-top: 4px; color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); white-space: normal; }
.pending-update { padding: 0 var(--space-5) var(--space-3); } .pending-update { padding: 0 var(--space-5) var(--space-3); }
.player-details td { background: var(--surface-sunken); }
.player-details dl { display: flex; flex-wrap: wrap; gap: 16px 32px; margin: 12px 0 0; }
.player-details dt { color: var(--text-secondary); font-size: var(--text-xs); }
.player-details dd { margin: 4px 0 0; font-variant-numeric: tabular-nums; }
.text-button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; } .text-button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.page { display: flex; flex-direction: column; gap: var(--space-5); } .page { display: flex; flex-direction: column; gap: var(--space-5); }
.filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); } .filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
.search { grid-column: 1 / -1; }
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; } .field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
label { color: var(--text-secondary); font-size: var(--text-xs); } label { color: var(--text-secondary); font-size: var(--text-xs); }
input, select, button { font: inherit; } input, select, button { font: inherit; }
@@ -340,21 +331,20 @@
.rankings-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); padding: var(--space-5); } .rankings-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); padding: var(--space-5); }
h2 { margin: 0 0 var(--space-1); font-size: var(--text-lg); letter-spacing: var(--tracking-tight); } h2 { margin: 0 0 var(--space-1); font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
.rankings-header p { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); max-width: 70ch; } .rankings-header p { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); max-width: 70ch; }
.refresh-status { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; text-align: right; } .table-scroll { position: relative; max-width: 100%; overflow-x: auto; }
.table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); } table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); }
th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); } th, td { padding: 12px 16px; border-bottom: 1px solid var(--border-subtle); }
thead th { background: var(--surface-sunken); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); } thead th { background: var(--surface-sunken); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
.rank { width: 65px; text-align: center; color: var(--text-tertiary); font-variant-numeric: tabular-nums; } .rank { width: 65px; text-align: center; color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.rank span { display: inline-grid; min-width: 30px; height: 30px; place-items: center; } .rank span { display: inline-grid; min-width: 30px; height: 30px; place-items: center; }
.rank .podium { border-radius: var(--radius-sm); color: var(--accent-primary-hover); background: color-mix(in srgb, var(--accent-primary) 12%, transparent); font-weight: var(--weight-semibold); } .rank .podium { border-radius: var(--radius-sm); color: var(--text-primary); background: var(--surface-raised); font-weight: var(--weight-semibold); }
.player { position: sticky; left: 0; z-index: 1; min-width: 180px; max-width: 320px; text-align: left; } .player { position: sticky; left: 0; z-index: 1; min-width: 180px; max-width: 320px; text-align: left; }
tbody .player { background: var(--surface); font-weight: var(--weight-medium); } tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
.player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; } .player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; }
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
.stat { text-align: right; white-space: nowrap; } .stat { text-align: right; white-space: nowrap; }
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } td.stat { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
.stat.selected { color: var(--accent-primary-hover); } .stat.selected { color: var(--text-primary); }
th.stat.selected { color: var(--accent-primary-hover); }
.sample { display: block; max-width: 22rem; margin-top: var(--space-2); white-space: normal; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; color: var(--text-secondary); } .sample { display: block; max-width: 22rem; margin-top: var(--space-2); white-space: normal; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; color: var(--text-secondary); }
td.selected { font-weight: var(--weight-semibold); } td.selected { font-weight: var(--weight-semibold); }
th button { display: inline-flex; gap: 6px; align-items: center; background: transparent; border: 0; padding: 3px 0; cursor: pointer; color: inherit; white-space: nowrap; } th button { display: inline-flex; gap: 6px; align-items: center; background: transparent; border: 0; padding: 3px 0; cursor: pointer; color: inherit; white-space: nowrap; }
@@ -370,6 +360,13 @@
.tracking-notes > div { max-width: 100ch; } .tracking-notes > div { max-width: 100ch; }
.tracking-notes p { margin: var(--space-3) 0 0; } .tracking-notes p { margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.ranking-controls { display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
.all-stats { display: flex; align-items: center; gap: var(--space-1); white-space: nowrap; font-size: var(--text-xs); }
.all-stats input { width: 16px; height: 16px; min-height: 0; accent-color: var(--accent-primary); }
.mobile-filter-trigger, .mobile-totals { display: none; }
.mobile-totals { font-size: var(--text-xs); color: var(--text-secondary); }
.mobile-totals dl { display: flex; flex-wrap: wrap; gap: var(--space-3); }
.mobile-totals dd { margin: var(--space-1) 0 0; color: var(--text-primary); font-variant-numeric: tabular-nums; }
@media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 800px) { @media (max-width: 800px) {
.overview { grid-template-columns: repeat(2, minmax(0, 1fr)); } .overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -382,10 +379,20 @@
.page { gap: var(--space-4); } .page { gap: var(--space-4); }
.filters { gap: var(--space-3); } .filters { gap: var(--space-3); }
.search { grid-column: 1 / -1; } .search { grid-column: 1 / -1; }
.filters > .field:nth-child(3) { grid-column: 1 / -1; } .desktop-filters, .overview { display: none; }
.mobile-filter-trigger, .mobile-totals { display: block; }
.scope-row { font-size: var(--text-sm); }
.scope-row > div { gap: var(--space-1) var(--space-2); }
.all-time { padding-left: var(--space-2); }
.filters { grid-template-columns: minmax(0, 1fr); }
.ranking-controls { justify-content: flex-start; }
.metric-description { display: none; }
.rankings:not(.show-all-stats) .stat:not(.selected) { display: none; }
.rank { width: 36px; }
.rank span { min-width: 20px; }
.player { position: static; min-width: 90px; max-width: 140px; }
.selected :global(.record-source) { max-width: 150px; }
.rankings-header { flex-direction: column; align-items: flex-start; padding: var(--space-4); } .rankings-header { flex-direction: column; align-items: flex-start; padding: var(--space-4); }
.refresh-status { text-align: left; }
.player { min-width: 180px; max-width: 220px; }
th, td { padding: var(--space-3); } th, td { padding: var(--space-3); }
.pagination { padding: var(--space-4); } .pagination { padding: var(--space-4); }
} }
@@ -2,19 +2,23 @@ import { error } from '@sveltejs/kit';
import { playerReport } from '$lib/server/reports/index.js'; import { playerReport } from '$lib/server/reports/index.js';
import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js'; import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
import { leaderboardReturnHref } from '$lib/leaderboard-navigation.js'; import { leaderboardReturnHref } from '$lib/leaderboard-navigation.js';
import type { LeaderboardPlayerCount } from '$lib/leaderboard.js';
export async function load({ params, url }: { params: { guid: string }; url: URL }) { export async function load({ params, url }: { params: { guid: string }; url: URL }) {
const game = url.searchParams.get('game'); const game = url.searchParams.get('game');
const map = url.searchParams.get('map'); const map = url.searchParams.get('map');
const requestedMetric = url.searchParams.get('metric'); const requestedMetric = url.searchParams.get('metric');
const players = url.searchParams.get('players');
if ((game !== null && game !== 't5' && game !== 't6') || (map !== null && map.length > 1024) if ((game !== null && game !== 't5' && game !== 't6') || (map !== null && map.length > 1024)
|| (requestedMetric !== null && !isLeaderboardMetric(requestedMetric))) { || (requestedMetric !== null && !isLeaderboardMetric(requestedMetric))
error(400, 'Use game t5 or t6, a supported metric, and a map key up to 1024 characters.'); || (players !== null && !/^[1-4]$/.test(players))) {
error(400, 'Use game t5 or t6, a supported metric, players 1–4, and a map key up to 1024 characters.');
} }
const metric = requestedMetric !== null && isLeaderboardMetric(requestedMetric) ? requestedMetric : 'highestRound'; const metric = requestedMetric !== null && isLeaderboardMetric(requestedMetric) ? requestedMetric : 'highestRound';
const options = { game: game || undefined, map: map || undefined, metric }; const options = { game: game || undefined, map: map || undefined,
players: players === null ? undefined : Number(players) as LeaderboardPlayerCount, metric };
const { profile, entry } = await playerReport(params.guid, Number(url.searchParams.get('offset') ?? 0), options); const { profile, entry } = await playerReport(params.guid, Number(url.searchParams.get('offset') ?? 0), options);
if (!profile) error(404, 'No recorded stats found for this player'); if (!profile) error(404, 'No recorded stats found for this player');
return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')), return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
scope: { game: game ?? '', map: map ?? '', metric, entry } }; scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry } };
} }
+105 -130
View File
@@ -1,183 +1,158 @@
<script lang="ts"> <script lang="ts">
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import { onMount } from 'svelte';
import { contextHref } from '$lib/navigation-context.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { goto, invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { leaderboardMetrics, playerStatMetrics, type PlayerProfile, type PlayerStatMetric, type LeaderboardMetric, type LeaderboardEntry } from '$lib/leaderboard.js'; import { contextHref } from '$lib/navigation-context.js';
import { leaderboardMetrics, playerStatMetrics, type PlayerProfile, type LeaderboardMetric, type LeaderboardEntry } from '$lib/leaderboard.js';
import { efficiencyMetrics, efficiencyValue, efficiencyEligible, efficiencyRequirement, efficiencySample, formatEfficiencyValue, isEfficiencyMetric } from '$lib/leaderboard-efficiency.js'; import { efficiencyMetrics, efficiencyValue, efficiencyEligible, efficiencyRequirement, efficiencySample, formatEfficiencyValue, isEfficiencyMetric } from '$lib/leaderboard-efficiency.js';
import { profileViewHref } from '$lib/leaderboard-navigation.js'; import { profileViewHref } from '$lib/leaderboard-navigation.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { serverTitle } from '$ui/server-labels.js';
import { pollVisible } from '$ui/poll.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import CopyValue from '$ui/components/CopyValue.svelte'; import CopyValue from '$ui/components/CopyValue.svelte';
import Badge from '$ui/components/primitives/Badge.svelte'; import Freshness from '$ui/components/Freshness.svelte';
import RecordSource from '$ui/components/RecordSource.svelte'; import RecordSource from '$ui/components/RecordSource.svelte';
import PlayerMapCards from '$ui/components/PlayerMapCards.svelte'; import PlayerMapCards from '$ui/components/PlayerMapCards.svelte';
import PlayerMatchCards from '$ui/components/PlayerMatchCards.svelte'; import PlayerMatchCards from '$ui/components/PlayerMatchCards.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
let { data }: { data: { profile: PlayerProfile; returnTo: string; let { data }: { data: { profile: PlayerProfile; returnTo: string;
scope: { game: string; map: string; metric: LeaderboardMetric; entry: LeaderboardEntry | null } } } = $props(); scope: { game: string; map: string; players?: string; metric: LeaderboardMetric; entry: LeaderboardEntry | null } } } = $props();
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }); const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
const profile = $derived(data.profile); const profile = $derived(data.profile);
const stats = $derived(profile.stats); const stats = $derived(profile.stats);
const scope = $derived(data.scope); const scope = $derived(data.scope);
const returnTo = $derived(`${$page.url.pathname}${$page.url.search}`);
const filteredMaps = $derived(profile.maps.filter(map => !scope.game || map.game === scope.game)); const filteredMaps = $derived(profile.maps.filter(map => !scope.game || map.game === scope.game));
const selectedMetric = $derived(leaderboardMetrics.find(metric => metric.key === scope.metric)!); const selectedMetric = $derived(leaderboardMetrics.find(metric => metric.key === scope.metric)!);
const selectedValue = $derived(scope.entry ? isEfficiencyMetric(scope.metric) const selectedValue = $derived(scope.entry ? isEfficiencyMetric(scope.metric) ? formatEfficiencyValue(scope.entry[scope.metric], scope.metric) : number.format(scope.entry[scope.metric]) : '—');
? formatEfficiencyValue(scope.entry[scope.metric], scope.metric) : number.format(scope.entry[scope.metric]) : '—'); const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : 'All games';
const squadName = (players?: string) => ({ '1': 'Solo', '2': 'Duo', '3': 'Trio', '4': 'Four players' } as Record<string, string>)[players ?? ''] ?? 'All squad sizes';
const scopeLabel = $derived(`${gameName(scope.game)} · ${profile.maps.find(map => map.key === scope.map)?.name ?? (scope.map || 'All maps')} · ${squadName(scope.players)}`);
const scopedLeaderboard = $derived.by(() => { const scopedLeaderboard = $derived.by(() => {
const params = new URLSearchParams({ metric: scope.metric }); const params = new URLSearchParams({ metric: scope.metric, map: scope.map });
if (scope.game) params.set('game', scope.game); if (scope.game) params.set('game', scope.game);
if (scope.map) params.set('map', scope.map); if (scope.players) params.set('players', scope.players);
return `/leaderboard?${params}`; return `/leaderboard?${params}`;
}); });
const date = (value: string) => $timeFormat.dateTime(value); const date = (value: string) => $timeFormat.dateTime(value);
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly PlayerStatMetric[]; const overviewMetrics = playerStatMetrics.filter(metric => ['kills', 'roundsSurvived', 'revives', 'gamesCompleted'].includes(metric.key));
let refreshing = $state(false); const otherMetrics = playerStatMetrics.filter(metric => ['headshots', 'downs', 'bestKills', 'bestScore'].includes(metric.key));
let refreshError = $state(''); let filterOpen = $state(false);
let filtering = $state(false); let filtering = $state(false);
let refreshError = $state('');
let updatedAt = $state<number | null>(null);
let pendingUrl: URL | undefined; let pendingUrl: URL | undefined;
let navigationId = 0; let navigationId = 0;
async function updateScope(changes: Record<string, string>) { async function updateScope(changes: Record<string, string>) {
const url = new URL(profileViewHref(pendingUrl ?? $page.url, changes), $page.url); const url = new URL(profileViewHref(pendingUrl ?? $page.url, changes), $page.url);
pendingUrl = url; pendingUrl = url;
const id = ++navigationId; const id = ++navigationId;
filtering = true; filtering = true; refreshError = '';
refreshError = ''; try { await goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true }); updatedAt = Date.now(); }
try { await goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true }); } catch { refreshError = 'Could not update player filters.'; }
catch { refreshError = 'Unable to update the player filters. Please try again.'; }
finally { if (id === navigationId) { pendingUrl = undefined; filtering = false; } } finally { if (id === navigationId) { pendingUrl = undefined; filtering = false; } }
} }
async function refresh() { async function refresh() {
refreshing = true; if (filtering) return;
refreshError = ''; try { await invalidateAll(); updatedAt = Date.now(); refreshError = ''; }
try { await invalidateAll(); } catch { refreshError = 'Player updates paused.'; return false; }
catch { refreshError = 'Unable to refresh player stats. Please try again.'; }
finally { refreshing = false; }
} }
onMount(() => { updatedAt = Date.now(); return pollVisible(refresh, 30_000, false); });
</script> </script>
<svelte:head><title>{profile.name} · Player stats · Electric Cherry</title></svelte:head> <svelte:head><title>{profile.name} · Player stats · Electric Cherry</title></svelte:head>
<section class="profile"> <section class="profile">
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats"> <SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Lifetime Zombies stats and completed matches">
{#snippet crumbs()}<Breadcrumbs fallback={data.returnTo} items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} />{/snippet} {#snippet crumbs()}<Breadcrumbs fallback={data.returnTo} items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} />{/snippet}
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet} {#snippet actions()}<Freshness {updatedAt} error={refreshError} intervalLabel="every 30 s" onRetry={() => void refresh()} />{/snippet}
</SectionHeader> </SectionHeader>
<div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div> <nav class="jump-links" aria-label="Player sections"><a href="#overview">Overview</a><a href="#maps-title">Maps</a><a href="#history-title">Matches</a></nav>
{#if refreshError}<p role="alert">{refreshError}</p>{/if} <section id="overview" aria-labelledby="overview-title">
<h2 id="overview-title" class="sr-only">Player overview</h2>
<section class="panel selection" aria-labelledby="selection-title" aria-busy={filtering}> <div class="scope-summary"><div><strong>{selectedMetric.label}</strong><p>{scopeLabel}</p></div><Button size="sm" variant="secondary" onclick={() => filterOpen = true}>Filters</Button></div>
<div class="panel-heading"><h2 id="selection-title">Selected leaderboard</h2><p>Choose the competition and match history to explore. Lifetime stats below always cover all games and maps.</p></div> <div class="hero-stats">
<div class="scope-controls"> <div class="stat"><span>Rank in selection</span><strong>{scope.entry ? `#${number.format(scope.entry.rank)}` : '—'}</strong><small>{scope.entry ? `${selectedMetric.label}: ${selectedValue}` : profile.matchesTotal ? 'Not yet qualified for this ranking' : 'No completed matches in this selection'}</small><a href={scopedLeaderboard}>Selected leaderboard</a></div>
<div class="stat"><span>Lifetime best round</span><strong>{number.format(stats.highestRound)}</strong><RecordSource evidence={profile.records.highestRound} {returnTo} metric="Lifetime best round" /></div>
<div class="stat"><span>Lifetime games played</span><strong>{number.format(stats.gamesPlayed)}</strong><small>{number.format(profile.maps.length)} {profile.maps.length === 1 ? 'map' : 'maps'}</small></div>
</div>
{#if isEfficiencyMetric(scope.metric)}<p class="note">Ranking requires {efficiencyRequirement(scope.metric).toLowerCase()} in the selection.</p>{/if}
<div class="stats">{#each overviewMetrics as metric}<div class="stat compact"><span>Lifetime {metric.label.toLowerCase()}</span><strong>{number.format(stats[metric.key])}</strong></div>{/each}</div>
</section>
<section class="panel" aria-labelledby="maps-title">
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Lifetime personal bests across all squad sizes. Each record links to the match that established it.</p></div>
<div class="mobile-records"><PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
<div class="table-scroll desktop-records" role="region" aria-label="Per-map player records" tabindex="0">
<table><thead><tr><th scope="col">Map</th>{#each records as record}<th scope="col">{record.label}</th>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th scope="col">Total rounds</th></tr></thead>
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small></th>{#each records as record}<td><a href={contextHref(`/matches/${map.records[record.key].matchId}`, $page.url)} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? date(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a></td>{/each}<td>{number.format(map.gamesPlayed)}</td><td>{number.format(map.kills)}</td><td>{number.format(map.roundsSurvived)}</td></tr>{/each}</tbody>
</table>
</div>
</section>
<section class="panel" aria-labelledby="history-title">
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>{scopeLabel}. Stats reflect this player's participation.</p></div>
{#if profile.matches.length}
<div class="mobile-records"><PlayerMatchCards matches={profile.matches} {returnTo} /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable match history for keyboard users.) -->
<div class="table-scroll desktop-records" role="region" aria-label="Player match history" tabindex="0">
<table><thead><tr><th scope="col">Match / server</th><th scope="col">First observed</th><th scope="col">Round</th><th scope="col">Kills</th><th scope="col">Revives</th><th scope="col">Downs</th><th scope="col">Completion</th></tr></thead><tbody>{#each profile.matches as match (match.id)}<tr><th scope="row"><a href={contextHref(`/matches/${match.id}`, $page.url)}>{match.mapName} · Match #{match.id}</a><small>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</small></th><td>{date(match.startedAt)}</td><td>{number.format(match.highestRound)}</td><td>{number.format(match.kills)}</td><td>{number.format(match.revives)}</td><td>{number.format(match.downs)}</td><td>{match.completed ? 'Credited' : 'Not credited'}</td></tr>{/each}</tbody></table>
</div>
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(profile.matchesTotal)} {profile.matchesTotal === 1 ? 'game' : 'games'}</span><div><Button size="sm" href={`${profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })}#history-title`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`${profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })}#history-title`} disabled={profile.offset + profile.limit >= profile.matchesTotal}>Next</Button></div></div>
{:else}<EmptyState headingLevel={3} title="No completed matches in this selection" description="Choose another game, map, or squad size to explore this player's history.">{#snippet actions()}<Button size="sm" onclick={() => void updateScope({ game: '', map: '', players: '' })}>Show all completed matches</Button>{/snippet}</EmptyState>{/if}
</section>
<section class="panel" aria-labelledby="metrics-title"><div class="panel-heading"><h2 id="metrics-title">Lifetime statistics</h2><p>All games, maps and squad sizes. Only completed matches contribute.</p></div><dl class="metric-list">{#each otherMetrics as metric}<div><dt>{metric.label}</dt><dd>{number.format(stats[metric.key])}</dd></div>{/each}{#each efficiencyMetrics as metric}<div><dt>{metric.label}</dt><dd>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</dd><small>{efficiencySample(stats, metric.key)}</small><small>{efficiencyEligible(stats, metric.key) ? 'Qualified for lifetime ranking' : `${efficiencyRequirement(metric.key)} to rank`}</small></div>{/each}</dl></section>
<details class="note"><summary>Identity and how these stats work</summary><div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div><p>Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in progress and interrupted games do not contribute. Highest round includes mid-game joins; total rounds counts observed progression. Kills, headshots, revives and downs use each match's highest reported counters.</p><p>Highest reported score is the largest observed points balance, not total points earned. Completion requires the player to appear in the final report. Records follow the same GUID across name changes. Recorded presence does not establish continuous survival.</p></details>
</section>
<Dialog bind:open={filterOpen} title="Player filters" description="Choose the leaderboard ranking and match history. Lifetime records stay unchanged.">
<div class="scope-controls" aria-busy={filtering}>
<label>Game<select value={scope.game} onchange={event => void updateScope({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label> <label>Game<select value={scope.game} onchange={event => void updateScope({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
<label>Map<select value={scope.map} onchange={event => void updateScope({ map: event.currentTarget.value })}><option value="">All maps</option>{#if scope.map && !filteredMaps.some(map => map.key === scope.map)}<option value={scope.map}>{scope.map}</option>{/if}{#each filteredMaps as map (`${map.game}:${map.key}`)}<option value={map.key}>{map.name}{!scope.game ? ` · ${gameName(map.game)}` : ''}</option>{/each}</select></label> <label>Map<select value={scope.map} onchange={event => void updateScope({ map: event.currentTarget.value })}><option value="">All maps</option>{#if scope.map && !filteredMaps.some(map => map.key === scope.map)}<option value={scope.map}>{scope.map}</option>{/if}{#each filteredMaps as map (`${map.game}:${map.key}`)}<option value={map.key}>{map.name}{!scope.game ? ` · ${gameName(map.game)}` : ''}</option>{/each}</select></label>
<label>Rank by<select value={scope.metric} onchange={event => void updateScope({ metric: event.currentTarget.value })}>{#each leaderboardMetrics as metric (metric.key)}<option value={metric.key}>{metric.label}</option>{/each}</select></label> <label>Players<select value={scope.players ?? ''} onchange={event => void updateScope({ players: event.currentTarget.value })}><option value="">All squad sizes</option><option value="1">Solo</option><option value="2">Duo</option><option value="3">Trio</option><option value="4">Four players</option></select></label>
<label>Rank by<select value={scope.metric} onchange={event => void updateScope({ metric: event.currentTarget.value })}>{#each leaderboardMetrics as metric}<option value={metric.key}>{metric.label}</option>{/each}</select></label>
</div> </div>
<div class="scope-rank"> {#snippet actions()}<Button onclick={() => filterOpen = false}>Done</Button>{/snippet}
{#if scope.entry}<p><strong>Rank #{number.format(scope.entry.rank)}</strong> · {selectedMetric.label}: <strong>{selectedValue}</strong>{#if isEfficiencyMetric(scope.metric)}<small>{efficiencySample(scope.entry, scope.metric)} · {number.format(scope.entry.gamesPlayed)} games</small>{/if}</p> </Dialog>
{:else if profile.matchesTotal === 0}<p>No completed matches in this selection.</p>
{:else if isEfficiencyMetric(scope.metric)}<p>Not yet qualified for this ranking.<small>{efficiencyRequirement(scope.metric)} in this selection.</small></p>{/if}
<a href={scopedLeaderboard}>Open selected leaderboard</a>
</div>
</section>
<section aria-labelledby="totals-title">
<h2 id="totals-title">Lifetime stats</h2>
<div class="stats">
{#each playerStatMetrics as metric (metric.key)}
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong>{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={profile.records[metric.key]} />{/if}</div>
{/each}
</div>
</section>
<section aria-labelledby="metrics-title">
<h2 id="metrics-title">Player metrics</h2>
<div class="stats metrics">
{#each efficiencyMetrics as metric (metric.key)}
<div class="stat"><span>{metric.label}</span><strong>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</strong><small>{efficiencySample(stats, metric.key)}</small><small>{efficiencyEligible(stats, metric.key) ? 'Qualifies for lifetime ranking' : `${efficiencyRequirement(metric.key)} to rank`}</small></div>
{/each}
</div>
<p class="note">Only completed games contribute. Completion rate measures how often the player appeared in the final report. A dash means there is no denominator for that metric.</p>
</section>
<section class="panel" aria-labelledby="maps-title">
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Personal best rounds and every tracked stat across both games, ordered by highest round.</p></div>
<div class="mobile-records"><PlayerMapCards maps={profile.maps} metric={scope.metric} /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll desktop-records" role="region" aria-label="Per-map player records" tabindex="0">
<table>
<thead><tr><th scope="col">Map</th>{#each playerStatMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
{#each playerStatMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
{/each}</tbody>
</table>
</div>
</section>
<section class="panel" aria-labelledby="history-title">
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Completed matches in the selected game and map, most recently observed first. Stats reflect this player's participation.</p></div>
{#if profile.matches.length}
<div class="mobile-records"><PlayerMatchCards matches={profile.matches} /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll desktop-records" role="region" aria-label="Player match history" tabindex="0">
<table>
<thead><tr><th scope="col">Map / server</th><th scope="col">First observed</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{playerStatMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
<tbody>{#each profile.matches as match (match.id)}
<tr><th scope="row">{#if match.status === 'completed'}<a href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
{#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each}
</tr>
{/each}</tbody>
</table>
</div>
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(profile.matchesTotal)} games</span>
<div><Button size="sm" href={`${profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })}#history-title`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`${profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })}#history-title`} disabled={profile.offset + profile.limit >= profile.matchesTotal}>Next</Button></div>
</div>
{:else}<EmptyState title="No completed matches in this selection" description="Choose another game or map to explore this player's history.">{#snippet actions()}<Button size="sm" onclick={() => void updateScope({ game: '', map: '' })}>Show all completed matches</Button>{/snippet}</EmptyState>{/if}
</section>
<details class="note"><summary>How these stats work</summary>
<p>Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in progress and interrupted games do not contribute. Highest round includes mid-game joins; total rounds only counts observed round progression. Kills, headshots, revives and downs use each match's highest reported counters.</p>
<p>Highest reported score is the largest observed points balance, not total points earned. Completion requires the player to appear in the final report. Records stay with the same GUID across both games when a player changes names, and the latest reported name is displayed.</p>
</details>
</section>
<style> <style>
.profile { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; } .profile { display: flex; flex-direction: column; gap: var(--space-5); min-width: 0; }
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); } h2 { font-size: var(--text-lg); margin: 0 0 var(--space-2); scroll-margin-top: 6rem; }
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); scroll-margin-top: 6rem; }
a { color: var(--accent-primary-hover); } a { color: var(--accent-primary-hover); }
.stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); } .jump-links { display: flex; flex-wrap: wrap; gap: var(--space-4); font-size: var(--text-sm); }
.stat { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); } .jump-links a { display: inline-flex; align-items: center; min-height: 44px; }
.stat span { color: var(--text-secondary); font-size: var(--text-xs); } .scope-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); font-size: var(--text-sm); }
.stat strong { font-family: var(--font-mono); font-size: var(--text-2xl); } .scope-summary p { margin: var(--space-1) 0 0; color: var(--text-secondary); font-size: var(--text-xs); }
.record { border-color: var(--accent-primary); } .hero-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); }
.record strong, .best { color: var(--accent-primary-hover); } .stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin-top: var(--space-3); }
.metrics strong { font-size: var(--text-xl); } .stat { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-4); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
.panel { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; } .stat span, .stat a, .note, small { font-size: var(--text-xs); }
.panel-heading { padding: var(--space-5); } .stat span, small { color: var(--text-secondary); }
.panel-heading p, .note { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; } .stat strong { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; font-size: var(--text-2xl); }
.scope-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); padding: 0 var(--space-5) var(--space-4); } .compact strong { font-size: var(--text-xl); }
.scope-controls label { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; color: var(--text-secondary); font-size: var(--text-xs); } .panel { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
.scope-controls select { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); font: inherit; } .panel-heading { padding: var(--space-4); }
.scope-rank { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 0 var(--space-5) var(--space-5); font-size: var(--text-sm); } .panel-heading p, .note { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
.scope-rank p { margin: 0; } .panel-heading p { margin: 0; }
section > .note { margin-top: var(--space-3); } .table-scroll { position: relative; max-width: 100%; overflow-x: auto; }
.mobile-records { display: none; }
.table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); } table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; } th, td { padding: var(--space-3); border-top: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; }
thead th { background: var(--surface-sunken); color: var(--text-secondary); font-size: var(--text-xs); } thead th { background: var(--surface-sunken); color: var(--text-secondary); font-size: var(--text-xs); }
th:first-child { text-align: left; min-width: 200px; white-space: normal; overflow-wrap: anywhere; } th:first-child { text-align: left; min-width: 180px; white-space: normal; overflow-wrap: anywhere; }
td { font-variant-numeric: tabular-nums; } td { font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); font-weight: normal; } small { display: block; font-weight: normal; margin-top: var(--space-1); }
.status { text-transform: capitalize; }
.pagination, .pagination > div { display: flex; gap: var(--space-3); align-items: center; } .pagination, .pagination > div { display: flex; gap: var(--space-3); align-items: center; }
.pagination { padding: var(--space-4); justify-content: space-between; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); } .pagination { padding: var(--space-4); justify-content: space-between; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); }
summary { cursor: pointer; color: var(--text-secondary); } .metric-list { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); margin: 0; padding: 0 var(--space-4) var(--space-4); }
@media (max-width: 1000px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } } dt { color: var(--text-secondary); font-size: var(--text-xs); }
@media (max-width: 640px) { .mobile-records { display: block; } .desktop-records { display: none; } .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } .scope-controls { grid-template-columns: minmax(0, 1fr); } .scope-rank { align-items: flex-start; flex-direction: column; } } dd { margin: var(--space-1) 0 0; font-size: var(--text-lg); font-variant-numeric: tabular-nums; }
summary { cursor: pointer; min-height: 44px; }
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); margin-bottom: var(--space-3); }
.scope-controls { display: grid; gap: var(--space-4); }
.scope-controls label { display: grid; gap: var(--space-2); color: var(--text-secondary); font-size: var(--text-sm); }
.scope-controls select { width: 100%; }
.mobile-records { display: none; }
@media (max-width: 640px) { .hero-stats { gap: var(--space-2); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } .stats, .metric-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } .mobile-records { display: block; } .desktop-records { display: none; } .hero-stats .stat { padding: var(--space-2); overflow-wrap: anywhere; } }
</style> </style>
+2 -2
View File
@@ -4,10 +4,10 @@
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
</script> </script>
<svelte:head><title>Match History · Electric Cherry</title></svelte:head> <svelte:head><title>Matches · Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Match History" subtitle="Recorded matches across your instances." /> <SectionHeader title="Matches" subtitle="Recorded matches across your servers." />
<MatchHistory initial={data.history} /> <MatchHistory initial={data.history} />
</div> </div>
+12 -1
View File
@@ -22,11 +22,22 @@ describe('leaderboard profile navigation', () => {
expect(next.searchParams.get('map')).toBe('t5:theater'); expect(next.searchParams.get('map')).toBe('t5:theater');
const filtered = new URL(profileViewHref(current, { game: 't6', map: '' }), current); const filtered = new URL(profileViewHref(current, { game: 't6', map: '' }), current);
expect(filtered.searchParams.has('offset')).toBe(false); expect(filtered.searchParams.has('offset')).toBe(false);
expect(filtered.searchParams.has('map')).toBe(false); expect(filtered.searchParams.get('map')).toBe('');
expect(filtered.searchParams.get('game')).toBe('t6'); expect(filtered.searchParams.get('game')).toBe('t6');
expect(filtered.searchParams.get('returnTo')).toBe('/leaderboard?q=Ace'); expect(filtered.searchParams.get('returnTo')).toBe('/leaderboard?q=Ace');
}); });
it('preserves explicit all maps and squad scope through profile links and paging', () => {
const source = '/leaderboard?map=&players=2&metric=highestRound';
expect(leaderboardReturnHref(source)).toBe(source);
const profile = new URL(playerContextHref('101', source), 'http://localhost');
expect(profile.searchParams.get('map')).toBe('');
expect(profile.searchParams.get('players')).toBe('2');
const next = new URL(profileViewHref(profile, { offset: '25' }), profile);
expect(next.searchParams.get('map')).toBe('');
expect(next.searchParams.get('players')).toBe('2');
});
it('restricts supplied return destinations to the local leaderboard', () => { it('restricts supplied return destinations to the local leaderboard', () => {
for (const value of [null, '', 'https://example.com/leaderboard', '//example.com/leaderboard', '/leaderboard/players/101', '/leaderboard/../settings', '/leaderboard\\@example.com']) { for (const value of [null, '', 'https://example.com/leaderboard', '//example.com/leaderboard', '/leaderboard/players/101', '/leaderboard/../settings', '/leaderboard\\@example.com']) {
expect(leaderboardReturnHref(value)).toBe('/leaderboard'); expect(leaderboardReturnHref(value)).toBe('/leaderboard');
+26 -3
View File
@@ -1,15 +1,38 @@
import { expect, it, vi } from 'vitest'; import { beforeEach, expect, it, vi } from 'vitest';
import { GET } from '../../src/routes/api/leaderboard/+server.js'; import { GET } from '../../src/routes/api/leaderboard/+server.js';
import { load } from '../../src/routes/leaderboard/+page.server.js'; import { load } from '../../src/routes/leaderboard/+page.server.js';
import { defaultLeaderboardMap } from '../../src/lib/server/fleet/leaderboard.js';
vi.mock('../../src/routes/api/leaderboard/+server.js', () => ({ GET: vi.fn() })); vi.mock('../../src/routes/api/leaderboard/+server.js', () => ({ GET: vi.fn() }));
vi.mock('../../src/lib/server/fleet/leaderboard.js', () => ({ defaultLeaderboardMap: vi.fn() }));
beforeEach(() => { vi.clearAllMocks(); });
it('requests the same 25-player page on initial load and preserves filters without mutating the page URL', async () => { it('requests the same 25-player page on initial load and preserves filters without mutating the page URL', async () => {
vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], limit: 25, offset: 25 }))); vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], limit: 25, offset: 25 })));
const url = new URL('http://localhost/leaderboard?game=t5&map=theater&metric=kills&q=123&offset=25&limit=100'); const url = new URL('http://localhost/leaderboard?game=t5&map=theater&players=2&metric=kills&q=123&offset=25&limit=100');
const original = url.href; const original = url.href;
const data = await load({ url }); const data = await load({ url });
const requested = vi.mocked(GET).mock.calls[0][0].url; const requested = vi.mocked(GET).mock.calls[0][0].url;
expect(requested.searchParams.get('limit')).toBe('25'); expect(requested.searchParams.get('limit')).toBe('25');
for (const key of ['game', 'map', 'metric', 'q', 'offset']) expect(requested.searchParams.get(key)).toBe(url.searchParams.get(key)); for (const key of ['game', 'map', 'players', 'metric', 'q', 'offset']) expect(requested.searchParams.get(key)).toBe(url.searchParams.get(key));
expect(url.href).toBe(original); expect(url.href).toBe(original);
expect(data.leaderboard.limit).toBe(25); expect(data.leaderboard.limit).toBe(25);
}); });
it('opens a missing map at a recorded map in the selected game and squad without a whole-fleet report', async () => {
vi.mocked(defaultLeaderboardMap).mockReturnValue('t6:town');
const url = new URL('http://localhost/leaderboard?game=t6&players=2&metric=kills&q=Cherry&offset=25');
const original = url.href;
await expect(load({ url })).rejects.toMatchObject({ status: 307, location: '/leaderboard?game=t6&players=2&metric=kills&q=Cherry&map=t6%3Atown' });
expect(defaultLeaderboardMap).toHaveBeenCalledWith({ game: 't6', players: 2 });
expect(GET).not.toHaveBeenCalled(); expect(url.href).toBe(original);
});
it('honors explicit All maps and handles an empty recorded-map catalog without redirecting', async () => {
vi.mocked(GET).mockResolvedValue(new Response(JSON.stringify({ entries: [], maps: [], limit: 25 })));
await load({ url: new URL('http://localhost/leaderboard?map=&players=3') });
expect(defaultLeaderboardMap).not.toHaveBeenCalled();
expect(vi.mocked(GET).mock.calls[0][0].url.searchParams.get('map')).toBe('');
vi.mocked(defaultLeaderboardMap).mockReturnValue(null);
vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], maps: [], limit: 25 })));
expect(await load({ url: new URL('http://localhost/leaderboard') })).toMatchObject({ leaderboard: { entries: [], maps: [] } });
expect(defaultLeaderboardMap).toHaveBeenCalledOnce();
});
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type Database from 'better-sqlite3';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { await fixture.cleanup(); });
function seed(handle: Database.Database, id: number, players: number | null, options: {
guid?: string; round?: number; game?: string; map?: string; mapName?: string; status?: string; hasStats?: number;
} = {}) {
const { guid = 'ace', round = 20, game = 't5', map = 'theater', mapName = 'Kino Der Toten', status = 'completed', hasStats = 1 } = options;
const timestamp = new Date(Date.UTC(2026, 9, 1, 0, id)).toISOString();
handle.prepare(`INSERT INTO leaderboard_matches
(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_players,highest_round,last_event,last_fingerprint)
VALUES (?,'removed-server',?,?,?,?,?,?,?,?,?,?,'game_end','fixture')`)
.run(id, game, map, `${game}:${map}`, mapName, timestamp, timestamp, timestamp, status, players, round);
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)`)
.run(game, guid, guid, timestamp, timestamp);
handle.prepare(`INSERT INTO leaderboard_match_players
(match_id,guid,highest_round,kills,best_score,rounds_survived,has_stats,completed,first_seen_at,last_seen_at)
VALUES(?,?,?,100,1000,?, ?,1,?,?)`).run(id, guid, round, round, hasStats, timestamp, timestamp);
}
describe('leaderboard starting-player scopes', () => {
it('separates each squad and its cache, with unknown starts available only in All players', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
seed(db(), 1, null);
for (const players of [1, 2, 3, 4] as const) seed(db(), players + 1, players, { round: players < 3 ? 20 : players * 10 });
seed(db(), 6, 1, { guid: 'blake', round: 30 });
seed(db(), 7, 2, { guid: 'blake', round: 10 });
seed(db(), 8, null, { guid: 'legacy', round: 99 });
const { getLeaderboard, getPlayerLeaderboardEntry } = await import('../../src/lib/server/fleet/leaderboard.js');
const all = getLeaderboard();
expect(all).toMatchObject({ total: 3, summary: { matches: 8, highestRound: 99 } });
for (const players of [1, 2, 3, 4, 2, 1] as const) {
const board = getLeaderboard({ players });
expect(board.summary.matches).toBe(players < 3 ? 2 : 1);
expect(board.entries.some(entry => entry.guid === 'legacy')).toBe(false);
const ace = board.entries.find(entry => entry.guid === 'ace')!;
expect(ace.gamesPlayed).toBe(1);
expect(ace.rank).toBe(players === 1 ? 2 : 1);
// All three equal-valued records must come from this squad, not
// the earlier unknown-start or solo match with matching values.
for (const record of Object.values(ace.records)) expect(record.matchId).toBe(players + 1);
expect(getPlayerLeaderboardEntry('ace', { players })).toEqual(ace);
}
expect(getLeaderboard()).toEqual(all);
});
it('filters profile history and rank while keeping explicitly lifetime records intact', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
for (let id = 1; id <= 30; id++) seed(db(), id, 2);
seed(db(), 31, 1, { round: 40 }); seed(db(), 32, null, { round: 99 });
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const lifetime = getPlayerProfile('ace')!;
const first = getPlayerProfile('ace', 0, { players: 2 })!;
const last = getPlayerProfile('ace', 999, { players: 2, map: 't5:theater', game: 't5' })!;
expect(first).toMatchObject({ matchesTotal: 30, offset: 0 }); expect(first.matches).toHaveLength(25);
expect(last).toMatchObject({ matchesTotal: 30, offset: 25 }); expect(last.matches).toHaveLength(5);
expect(new Set([...first.matches, ...last.matches].map(match => match.id))).toEqual(new Set(Array.from({ length: 30 }, (_, i) => i + 1)));
expect(first.stats).toEqual(lifetime.stats); expect(first.records).toEqual(lifetime.records); expect(first.maps).toEqual(lifetime.maps);
expect(getPlayerProfile('ace', 25, { players: 4 })).toMatchObject({ matchesTotal: 0, matches: [], offset: 0, stats: lifetime.stats });
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const scoped = await load({ params: { guid: 'ace' }, url: new URL('http://localhost/leaderboard/players/ace?players=2&map=t5%3Atheater') });
expect(scoped).toMatchObject({ profile: { matchesTotal: 30, stats: { gamesPlayed: 32, highestRound: 99 } },
scope: { players: '2', entry: { gamesPlayed: 30, highestRound: 20, records: { highestRound: { matchId: 1 } } } } });
});
it('chooses a deterministic usable default map within game and squad, ignoring unfinished and statless records', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
seed(db(), 1, 2); seed(db(), 2, 3, { map: 'moon', mapName: 'Moon' });
seed(db(), 3, 4, { game: 't6', map: 'town', mapName: 'Town' });
seed(db(), 4, 2, { map: 'empty', mapName: 'A statless map', hasStats: 0 });
seed(db(), 5, 2, { map: 'active', mapName: 'A match in progress', status: 'active' });
const { defaultLeaderboardMap } = await import('../../src/lib/server/fleet/leaderboard.js');
expect(defaultLeaderboardMap()).toBe('t5:theater');
expect(defaultLeaderboardMap({ players: 3 })).toBe('t5:moon');
expect(defaultLeaderboardMap({ game: 't6' })).toBe('t6:town');
expect(defaultLeaderboardMap({ game: 't5', players: 4 })).toBeNull();
});
it('keeps API absent-map searches global while filtering all contributions by the selected squad', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
seed(db(), 1, 2); seed(db(), 2, 2, { map: 'moon', mapName: 'Moon' }); seed(db(), 3, null, { round: 99 });
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
expect(await (await GET({ url: new URL('http://localhost/api/leaderboard?players=2') })).json())
.toMatchObject({ summary: { matches: 2, highestRound: 20 }, entries: [{ gamesPlayed: 2 }] });
expect(await (await GET({ url: new URL('http://localhost/api/leaderboard') })).json())
.toMatchObject({ summary: { matches: 3, highestRound: 99 } });
for (const players of ['0', '5', '', '2.0', '-1', 'two', '01']) {
const response = await GET({ url: new URL(`http://localhost/api/leaderboard?players=${players}`) });
expect(response.status).toBe(400);
}
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
await expect(load({ params: { guid: 'ace' }, url: new URL('http://localhost/leaderboard/players/ace?players=5') }))
.rejects.toMatchObject({ status: 400 });
});
});
+29 -6
View File
@@ -17,14 +17,16 @@ function scope(search = '') {
(page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost/leaderboard${search}`) }); (page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost/leaderboard${search}`) });
} }
function html(data = report()) { return render(Leaderboard, { props: { data: { leaderboard: data, user: null, setupRequired: false } } }).body; } function html(data = report()) { return render(Leaderboard, { props: { data: { leaderboard: data, user: null, setupRequired: false } } }).body; }
beforeEach(() => scope()); beforeEach(() => scope('?map='));
it('shows prominent totals and restored stat columns, preserving ranks and collapsed identity and evidence', () => { it('shows compact totals and core stat columns, preserving ranks without repeated row disclosures', () => {
const body = html(); const body = html();
const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>')); const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>'));
expect(headers.match(/scope="col"/g)).toHaveLength(9); expect(headers.match(/scope="col"/g)).toHaveLength(6);
expect(headers).toContain('Highest round'); expect(headers).toContain('Total kills'); expect(headers).toContain('Games played'); expect(headers).toContain('Highest round'); expect(headers).toContain('Total kills'); expect(headers).toContain('Games played');
for (const label of ['Total rounds', 'Headshots', 'Revives', 'Downs']) expect(headers).toContain(label); expect(headers).toContain('Total rounds');
for (const label of ['Headshots', 'Revives', 'Downs', 'Games completed', 'Best game kills', 'Highest reported score']) expect(body).toContain(label);
expect(body).toContain('Show all stats');
expect(body).toMatch(/<span[^>]*>27<\/span>/); expect(body).toMatch(/<span[^>]*>27<\/span>/);
expect(body).toContain('26–26 of 60 players'); expect(body).toContain('26–26 of 60 players');
const overview = body.slice(body.indexOf('class="overview'), body.indexOf('class="rankings')); const overview = body.slice(body.indexOf('class="overview'), body.indexOf('class="rankings'));
@@ -34,7 +36,12 @@ it('shows prominent totals and restored stat columns, preserving ranks and colla
expect(overview).not.toContain('<details'); expect(overview).not.toContain('<details');
expect(body).not.toMatch(/<details[^>]*open/); expect(body).not.toMatch(/<details[^>]*open/);
expect(body).not.toContain('Copy GUID'); expect(body).not.toContain('Every round counts'); expect(body).not.toContain('Copy GUID'); expect(body).not.toContain('Every round counts');
expect(body).toContain('Record details'); expect(body).toContain('First observed at round 8'); expect(body).not.toContain('Record details'); expect(body).not.toContain('Hide details');
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/<a\b/g)).toHaveLength(2);
expect(rows).not.toContain('<details');
expect(rows).toContain('Match #71');
expect(body).toContain('including players who joined mid-game');
expect(body).not.toContain('>Search</button>'); expect(body).not.toContain('>Search</button>');
}); });
@@ -43,7 +50,7 @@ it('deduplicates the selected metric and preserves scope and pagination on playe
const data = report(); data.metric = 'kills'; const data = report(); data.metric = 'kills';
const body = html(data); const body = html(data);
const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>')); const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>'));
expect(headers.match(/scope="col"/g)).toHaveLength(8); expect(headers.match(/scope="col"/g)).toHaveLength(5);
expect(body).toContain('offset%3D25'); expect(body).toContain('q%3DCherry'); expect(body).toContain('offset%3D25'); expect(body).toContain('q%3DCherry');
scope('?game=t5&map=t5%3Atheater&metric=highestRound&q=Cherry&offset=25'); scope('?game=t5&map=t5%3Atheater&metric=highestRound&q=Cherry&offset=25');
const recordBody = html(); const recordBody = html();
@@ -52,6 +59,22 @@ it('deduplicates the selected metric and preserves scope and pagination on playe
expect(recordBody).toContain('Selection record'); expect(recordBody).not.toContain('Fleet record'); expect(recordBody).toContain('Selection record'); expect(recordBody).not.toContain('Fleet record');
}); });
it('provides separate mobile filter controls and preserves explicit all-map and squad context', () => {
scope('?map=&players=2&metric=highestRound&offset=25');
const body = html();
expect(body).toContain('Leaderboard filters');
expect(body).toContain('id="leaderboard-players-mobile"');
expect(body).toContain('value="2" selected=""');
expect(body).toContain('All squad sizes');
expect(body).toContain('Solo'); expect(body).toContain('Duo'); expect(body).toContain('Trio'); expect(body).toContain('Four players');
expect(body).toContain('unknown starting counts appear only under All squad sizes');
expect(body).toContain('players%3D2');
expect(body).toContain('map%3D');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
expect(new Set(ids).size).toBe(ids.length);
expect(body).not.toMatch(/>Refresh<\/span>/);
});
it('keeps qualification and denominator visible for efficiency rankings and missing values distinct', () => { it('keeps qualification and denominator visible for efficiency rankings and missing values distinct', () => {
scope('?metric=headshotRate'); scope('?metric=headshotRate');
const data = report(); data.metric = 'headshotRate'; const data = report(); data.metric = 'headshotRate';
+16 -2
View File
@@ -15,7 +15,7 @@ beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-
afterEach(() => vi.useRealTimers()); afterEach(() => vi.useRealTimers());
it('renders one page heading, six columns, a combined identity, and compact dates', () => { 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; const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body;
expect(body.match(/<h[12][ >]/g)).toHaveLength(1); expect(body.match(/<h1[ >]/g)).toHaveLength(1);
expect(body.match(/scope="col"/g)).toHaveLength(6); 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).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).not.toContain('>Server</th>'); expect(body).not.toContain('Show 25 more');
@@ -28,7 +28,21 @@ it('preserves filters and page in match links and avoids linking deleted instanc
expect(body).toContain('/matches/1?returnTo=%2Fmatches'); expect(body).toContain('matchPage%3D2'); expect(body).toContain('/matches/1?returnTo=%2Fmatches'); expect(body).toContain('matchPage%3D2');
expect(body).toContain('matchSort%3Dround'); expect(body).toContain('Historical instance'); expect(body).toContain('matchSort%3Dround'); expect(body).toContain('Historical instance');
expect(body).not.toContain('href="/servers/'); expect(body).not.toContain('>Deleted<'); 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)'); expect(body).toContain('26–26 of 40 matches'); expect(body).toContain('Filters · 2');
});
it('keeps mobile filters and sorting in one labelled sheet with distinct controls and one named match link', () => {
const body = render(MatchHistory, { props: { initial: report() } }).body;
expect(body).toContain('Match filters');
expect(body).toContain('id="match-map-mobile"');
expect(body).toContain('id="match-sort-mobile"');
expect(body).toContain('id="match-order-mobile"');
expect(body).toContain('Show results');
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/<a\b/g)).toHaveLength(1);
expect(rows).toContain('Town Survival · Match #1');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
expect(new Set(ids).size).toBe(ids.length);
expect(body).toContain('Updates every 10 seconds');
}); });
it('labels unfinished rounds and missing durations without inventing final results or detail links', () => { 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 data = report(); data.matches = [{ ...match, status: 'interrupted', finalRound: null, durationMs: null, uniquePlayers: 0 }];