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
+6 -4
View File
@@ -1,7 +1,7 @@
import { leaderboardMetrics } from './leaderboard.js';
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. */
export function leaderboardReturnHref(value: string | null | undefined): string {
@@ -12,7 +12,7 @@ export function leaderboardReturnHref(value: string | null | undefined): string
const params = new URLSearchParams();
for (const key of contextKeys) {
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}` : ''}`;
} catch { return '/leaderboard'; }
@@ -26,9 +26,11 @@ export function playerContextHref(guid: string, source: string): string {
const game = original.get('game');
const map = original.get('map');
const metric = original.get('metric');
const players = original.get('players');
// Match the leaderboard's displayed defaults for older or hand-edited URLs.
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);
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);
params.delete('offset');
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);
}
return `${current.pathname}${params.size ? `?${params}` : ''}`;
+3
View File
@@ -101,9 +101,12 @@ export interface LeaderboardMap {
name: string;
}
export type LeaderboardPlayerCount = 1 | 2 | 3 | 4;
export interface LeaderboardOptions {
game?: string;
map?: string;
players?: LeaderboardPlayerCount;
metric?: LeaderboardMetric;
q?: string;
limit?: number;
+6 -4
View File
@@ -1,11 +1,12 @@
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';
interface RecordRequest extends Record<PersonalBestMetric, number> {
guid: string;
game?: string;
map?: string;
players?: LeaderboardPlayerCount;
}
/** 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) {
const batch = requests.slice(offset, offset + 100);
const parameters = batch.flatMap((request, index) => [index, request.guid, request.game ?? null,
request.map ?? null, request.highestRound, request.bestKills, request.bestScore]);
const rows = handle.prepare(`WITH requested (requestId, guid, game, map, highestRound, bestKills, bestScore) AS (
VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}
request.map ?? null, request.players ?? null, request.highestRound, request.bestKills, request.bestScore]);
const rows = handle.prepare(`WITH requested (requestId, guid, game, map, players, highestRound, bestKills, bestScore) AS (
VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}
), candidates AS (
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,
@@ -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
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.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)
), valuesByMetric AS (
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);
}
/** 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. */
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
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. */
export function getPlayerLeaderboardEntry(guid: string,
options: Pick<LeaderboardOptions, 'game' | 'map' | 'metric'> = {}): LeaderboardEntry | null {
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid);
options: Pick<LeaderboardOptions, 'game' | 'map' | 'players' | 'metric'> = {}): LeaderboardEntry | null {
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[] {
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] }));
}
@@ -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 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}`];
const parameters: string[] = [];
const parameters: Array<string | number> = [];
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.players) { conditions.push('m.start_players = ?'); parameters.push(options.players); }
const qualification = isEfficiencyMetric(metric)
? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL`
+ (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. */ }
let cache = scopeCaches.get(handle);
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);
if (!saved || revision < 0 || saved.revision !== revision) {
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}`;
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();
// 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,
@@ -22,11 +22,12 @@ export function getPlayerProfile(guid: string, requestedOffset = 0,
if (!player) return null;
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
if (!stats.gamesPlayed) return null;
const historyParameters = [guid];
const historyParameters: Array<string | number> = [guid];
let historyScope = scope;
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
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
: stats.gamesPlayed;
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>
+51 -31
View File
@@ -11,6 +11,8 @@
import { formatDuration } from '$ui/duration.js';
import Button from './primitives/Button.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 data = $state<MatchHistoryResponse | undefined>(untrack(() => initial));
@@ -18,6 +20,8 @@
let error = $state('');
let search = $state(untrack(() => matchApiParams($page.url).get('q') ?? ''));
let retry = $state(0);
let filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
let tableRegion = $state<HTMLDivElement>();
let pendingUrl: URL | undefined;
let navigationId = 0;
@@ -29,13 +33,14 @@
return `${endpoint}?${params}`;
});
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 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(() => {
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(() => {
const url = apiUrl;
@@ -49,7 +54,7 @@
if (signal.aborted || url !== apiUrl) return;
// Do not move keyboard users to another match while they are following a link.
if (loadedUrl !== url || !tableRegion?.contains(document.activeElement)) data = body;
loadedUrl = url; loading = false; error = '';
loadedUrl = url; loading = false; error = ''; checkedAt = Date.now();
} catch (cause) {
if (signal.aborted || url !== apiUrl) return;
error = cause instanceof Error ? cause.message : 'Could not load match history.';
@@ -71,27 +76,33 @@
</script>
<section class="history" aria-label="Match history">
<div class="filters">
{#if server === undefined}
<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>
{/if}
<label>Map<select value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
<option value="">All maps</option>
{#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}
</select></label>
{#if server === undefined}
<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); }}>
<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" class="sr-only">Press Enter to search by instance name.</span>
</form>
{/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 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 filterFields(suffix: string)}
<div class="filters">
{#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 for={`match-map${suffix}`}>Map<select id={`match-map${suffix}`} value={filters.map} onchange={event => update({ map: event.currentTarget.value })}>
<option value="">All maps</option>
{#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 === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
</select></label>
{#if server === undefined}<form role="search" onsubmit={event => { event.preventDefault(); update({ q: search.trim() }); if (suffix) filtersOpen = false; }}>
<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>
<span id={`match-search-help${suffix}`} class="sr-only">Press Enter to search by server name.</span>
</form>{/if}
<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}
</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 loading}<EmptyState title="Loading matches…" />
{:else if loadedUrl !== apiUrl && error}<EmptyState title="Match history unavailable" description="Retry to load this selection." />
@@ -112,11 +123,11 @@
<tr>
<td><time datetime={match.startedAt} title={$timeFormat.dateTime(match.startedAt)}>{matchStartedLabel(match.startedAt, $timeZone)}</time></td>
<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}
<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 !match.server_deleted_at}<a href={contextHref(`/servers/${encodeURIComponent(match.server)}?tab=matches`, $page.url)} title={match.server}>{matchInstanceLabel(match)}</a>
{:else}<span title={match.server}>{matchInstanceLabel(match)}</span><span> · Historical instance</span>{/if}
<span title={match.server}>{matchInstanceLabel(match)}</span>{#if match.server_deleted_at}<span> · Historical instance</span>{/if}
</div>{/if}
</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 { 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); }
.more-filters { padding-top: 25px; font-size: var(--text-sm); }
.more-filters label { margin-top: 10px; }
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); }
.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 { 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; }
@@ -165,7 +174,6 @@
a:hover { text-decoration: underline; }
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 a { color: inherit; text-decoration: none; }
.status { color: var(--text-secondary); }
.status.active { color: var(--accent-primary-hover); }
.status.interrupted { color: var(--state-warning); }
@@ -173,5 +181,17 @@
.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); }
.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>
+15 -54
View File
@@ -1,66 +1,27 @@
<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 RecordSource from '$ui/components/RecordSource.svelte';
let { maps, metric = 'highestRound' }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric } = $props();
let { maps, metric = 'highestRound', returnTo }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric; returnTo?: string } = $props();
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 = playerStatMetrics.filter(metric => metric.key !== 'highestRound' && metric.key !== 'kills');
const recordMetrics = ['highestRound', 'bestKills', 'bestScore'] as const satisfies readonly PersonalBestMetric[];
const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
</script>
<div class="map-cards">
{#each maps as map (`${map.game}:${map.key}`)}
<article class="map-card">
<header>
<p class="game">{gameName(map.game)}</p>
<h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3>
<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>
<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>
<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>
<p>{number.format(map.kills)} total kills · {number.format(map.roundsSurvived)} observed rounds</p>
</article>
{/each}
</div>
<style>
.map-cards { min-width: 0; }
.map-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
h3, h4, p, dl, dd { margin: 0; }
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; }
h4 { font-size: var(--text-sm); line-height: 1.5; }
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
h3, p, dl, dd { margin: 0; }
h3 { font-size: var(--text-md); }
header span, p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
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); }
.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>
+15 -52
View File
@@ -1,64 +1,27 @@
<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 Badge from '$ui/components/primitives/Badge.svelte';
let { matches }: { matches: PlayerMatch[] } = $props();
import { serverTitle } from '$ui/server-labels.js';
import { contextHref } from '$lib/navigation-context.js';
let { matches, returnTo }: { matches: PlayerMatch[]; returnTo?: string } = $props();
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>
<div class="match-cards">
{#each matches as match (match.id)}
<article class="match-card">
<header>
<p class="game">{gameName(match.game)}</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 class="server">{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</p>
</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>
<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>{serverTitle(match.server)}{match.server_deleted_at ? ' · Removed server' : ''}</p>
<p><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time> · {match.completed ? 'Completion credited' : 'No completion credit'}</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>
</article>
{/each}
</div>
<style>
.match-cards { min-width: 0; }
.match-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
article { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
h3, p, dl, dd { margin: 0; }
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; }
a { color: var(--accent-primary-hover); }
.game, .server, .observation, .match-id { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
.server { margin-top: var(--space-2); }
.match-info { display: grid; gap: var(--space-2); margin-top: var(--space-3); }
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); }
h3 { font-size: var(--text-sm); line-height: var(--leading-body); }
h3 a { display: inline-flex; align-items: center; min-height: 44px; color: var(--accent-primary-hover); }
p, dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
dl { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); 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; }
</style>
+3 -29
View File
@@ -2,36 +2,10 @@
import type { RecordEvidence } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { contextHref } from '$lib/navigation-context.js';
let { evidence, returnTo, disclosure = true }: { evidence: RecordEvidence; returnTo?: string; disclosure?: boolean } = $props();
const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II');
let { evidence, returnTo, metric = 'Record' }: { evidence: RecordEvidence; returnTo?: string; metric?: string; disclosure?: boolean } = $props();
</script>
<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>
<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>
<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); }
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>