Focus player records and add squad leaderboards
This commit is contained in:
+16
-12
@@ -1,10 +1,14 @@
|
||||
# Leaderboard
|
||||
|
||||
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
|
||||
and map filters limit which matches contribute to each player's statistics and
|
||||
with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game, map,
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
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
|
||||
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
|
||||
**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.
|
||||
These controls also filter match history; lifetime totals stay unchanged.
|
||||
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.
|
||||
|
||||
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**
|
||||
or **More match stats** for the remaining counters and source evidence. Desktop
|
||||
profiles retain the complete tables; both views use the same history filters
|
||||
game, record values and match identity up front. Record values link directly to
|
||||
their completed match, where the full evidence is available. Desktop profiles
|
||||
use compact tables; both views use the same history filters
|
||||
and pagination. History dates say **First observed**, since a missing 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
|
||||
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
|
||||
start was recorded. **Run details** shows when the player was first observed:
|
||||
at the recorded start, at a reported round, or unknown for older stored records.
|
||||
Each source names its match and map. Open the match to see completion time,
|
||||
server, whether its start was recorded, and player participation evidence.
|
||||
Presence is not proof of continuous survival. Equal personal bests link to the
|
||||
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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
@@ -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}` : ''}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { leaderboardReport } from '$lib/server/reports/index.js';
|
||||
import { json } from '@sveltejs/kit';
|
||||
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 }) {
|
||||
const params = url.searchParams;
|
||||
@@ -8,6 +9,7 @@ export async function GET({ url }: { url: URL }) {
|
||||
const map = params.get('map');
|
||||
const metric = params.get('metric');
|
||||
const q = params.get('q');
|
||||
const players = params.get('players');
|
||||
const rawLimit = params.get('limit');
|
||||
const rawOffset = params.get('offset');
|
||||
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')
|
||||
|| (map !== null && map.length > 1024) || (q !== null && q.length > 100)
|
||||
|| (metric !== null && !isLeaderboardMetric(metric))
|
||||
|| (players !== null && !/^[1-4]$/.test(players))
|
||||
|| !Number.isFinite(limit) || limit < 1 || limit > 100
|
||||
|| !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,
|
||||
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
|
||||
players: players === null ? undefined : Number(players) as LeaderboardPlayerCount,
|
||||
q: q ?? undefined, limit, offset }));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { GET } from '../api/leaderboard/+server.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { LeaderboardResponse } from '$lib/leaderboard.js';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import type { LeaderboardPlayerCount, LeaderboardResponse } from '$lib/leaderboard.js';
|
||||
import { leaderboardPageSize } from '$lib/leaderboard-updates.js';
|
||||
import { defaultLeaderboardMap } from '$lib/server/fleet/leaderboard.js';
|
||||
|
||||
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);
|
||||
reportUrl.searchParams.set('limit', String(leaderboardPageSize));
|
||||
const response = await GET({ url: reportUrl });
|
||||
|
||||
@@ -12,15 +12,17 @@
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.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';
|
||||
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const pageSize = leaderboardPageSize;
|
||||
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> = {
|
||||
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.',
|
||||
roundsSurvived: 'Round progression during observed participation, added across games.',
|
||||
headshots: 'Cumulative headshots across recorded games.',
|
||||
@@ -41,9 +43,10 @@
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
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 expandedPlayers = $state<string[]>([]);
|
||||
let filtersOpen = $state(false);
|
||||
let showAllStats = $state(false);
|
||||
let tableRegion = $state<HTMLDivElement>();
|
||||
let controller: AbortController | undefined;
|
||||
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 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 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))));
|
||||
@@ -60,11 +65,13 @@
|
||||
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 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 params = new URLSearchParams({ metric, limit: String(pageSize), offset: String(offset) });
|
||||
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);
|
||||
return `/api/leaderboard?${params}`;
|
||||
});
|
||||
@@ -76,8 +83,8 @@
|
||||
const report = initial.leaderboard;
|
||||
untrack(() => {
|
||||
++requestId; controller?.abort(); data = report; maps = report.maps;
|
||||
loadedUrl = apiUrl; loading = false; error = ''; checkedAt = null;
|
||||
pendingUpdate = null; expandedPlayers = []; updates.reset(apiUrl, report);
|
||||
loadedUrl = apiUrl; loading = false; error = ''; checkedAt = new Date();
|
||||
pendingUpdate = null; updates.reset(apiUrl, report);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,8 +114,7 @@
|
||||
if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`);
|
||||
const result: LeaderboardResponse = await response.json();
|
||||
if (id !== requestId || url !== apiUrl || changingFilters || pendingUrl) return;
|
||||
const inspecting = expandedPlayers.length > 0 || !!tableRegion?.contains(document.activeElement)
|
||||
|| !!tableRegion?.querySelector('details[open]');
|
||||
const inspecting = !!tableRegion?.contains(document.activeElement);
|
||||
const next = updates.receive(url, result, inspecting);
|
||||
if (!next) return;
|
||||
data = next.current;
|
||||
@@ -126,25 +132,18 @@
|
||||
}
|
||||
|
||||
function applyUpdate() {
|
||||
expandedPlayers = [];
|
||||
tableRegion?.querySelectorAll('details[open]').forEach(detail => detail.removeAttribute('open'));
|
||||
data = updates.apply();
|
||||
maps = data.maps;
|
||||
pendingUpdate = null;
|
||||
}
|
||||
|
||||
function togglePlayer(guid: string) {
|
||||
expandedPlayers = expandedPlayers.includes(guid)
|
||||
? expandedPlayers.filter(value => value !== guid) : [...expandedPlayers, guid];
|
||||
}
|
||||
|
||||
function updateFilters(changes: Record<string, string>) {
|
||||
function updateFilters(changes: Record<string, string | null>) {
|
||||
++requestId; controller?.abort(); loading = false;
|
||||
// Keep rapid changes together while the preceding navigation settles.
|
||||
const url = new URL(pendingUrl ?? $page.url);
|
||||
url.searchParams.delete('offset');
|
||||
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);
|
||||
}
|
||||
pendingUrl = url;
|
||||
@@ -156,7 +155,7 @@
|
||||
|
||||
function clearFilters() {
|
||||
search = '';
|
||||
updateFilters({ game: '', map: '', q: '' });
|
||||
updateFilters({ game: '', map: null, players: '', q: '' });
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -166,40 +165,49 @@
|
||||
</svelte:head>
|
||||
|
||||
<section class="page">
|
||||
<SectionHeader title="Leaderboard" subtitle="All-time player records.">
|
||||
{#snippet actions()}
|
||||
<Button size="sm" loading={loading} disabled={changingFilters} onclick={() => void refresh(apiUrl)}>Refresh</Button>
|
||||
{/snippet}
|
||||
<SectionHeader title="Leaderboard" subtitle="Records by map and starting squad.">
|
||||
{#snippet actions()}<div class="mobile-filter-trigger"><Button size="sm" onclick={() => filtersOpen = true}>Filters{filterCount ? ` · ${filterCount}` : ''}</Button></div>{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<div class="filters">
|
||||
<div class="field"><label for="leaderboard-game">Game</label>
|
||||
<select id="leaderboard-game" value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: '' })}>
|
||||
<option value="">All games</option>
|
||||
{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
|
||||
</select>
|
||||
{#snippet filterFields(suffix: string)}
|
||||
<div class="filters">
|
||||
<div class="field"><label for={`leaderboard-game${suffix}`}>Game</label>
|
||||
<select id={`leaderboard-game${suffix}`} value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: null })}>
|
||||
<option value="">All games</option>{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for={`leaderboard-map${suffix}`}>Map</label>
|
||||
<select id={`leaderboard-map${suffix}`} value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#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 === 't5' ? 'BO1' : 'BO2'}` : ''}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for={`leaderboard-players${suffix}`}>Players</label>
|
||||
<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}
|
||||
</select>
|
||||
</div>
|
||||
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); if (suffix) filtersOpen = false; }} role="search">
|
||||
<label for={`leaderboard-search${suffix}`}>Find a player</label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="field"><label for="leaderboard-map">Map</label>
|
||||
<select id="leaderboard-map" value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
|
||||
<option value="">All maps</option>
|
||||
{#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}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label for="leaderboard-metric">Rank by</label>
|
||||
<select id="leaderboard-metric" value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}>
|
||||
{#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
|
||||
</select>
|
||||
</div>
|
||||
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); }} role="search">
|
||||
<label for="leaderboard-search">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>
|
||||
</form>
|
||||
</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><span class="scope">{scope}</span><span class="all-time">All time</span></div>
|
||||
{#if game || map || query}<button class="text-button" onclick={clearFilters}>Clear filters</button>{/if}
|
||||
<div><span class="scope">{scope}</span><span class="all-time">{playersLabel}</span><span class="all-time">All time</span></div>
|
||||
{#if game || players || query}<button class="text-button" onclick={clearFilters}>Reset filters</button>{/if}
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
@@ -215,13 +223,13 @@
|
||||
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Unique players</small></div>
|
||||
</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><h2 id="rankings-title">{metricLabel}</h2>
|
||||
{#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 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>
|
||||
{#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}
|
||||
@@ -244,7 +252,7 @@
|
||||
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
|
||||
<th scope="row" class="player">
|
||||
<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>
|
||||
{#each columns as key (key)}
|
||||
<td class="stat" class:selected={key === metric}>
|
||||
@@ -252,24 +260,12 @@
|
||||
{#if isEfficiencyMetric(key)}
|
||||
<small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small>
|
||||
{/if}
|
||||
{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}
|
||||
<small class="record-map">{entry.records[key].mapName}</small>
|
||||
<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 key === metric && (key === 'highestRound' || key === 'bestKills' || key === 'bestScore')}
|
||||
<RecordSource evidence={entry.records[key]} metric={metricLabel} returnTo={`${$page.url.pathname}${$page.url.search}`} />
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</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}
|
||||
</tbody>
|
||||
</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.`}>
|
||||
{#snippet actions()}<Button size="sm" onclick={() => updateFilters({ metric: 'highestRound' })}>Rank by highest round</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{:else if query || game || map || offset}
|
||||
<EmptyState spacious title="No players found" description="Try a different game, map, or player name.">
|
||||
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet}
|
||||
{:else if query || game || map || players || offset}
|
||||
<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}>Reset filters</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{: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." />
|
||||
@@ -294,11 +290,12 @@
|
||||
<div class="loading" role="status">Loading player records…</div>
|
||||
{/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">
|
||||
<summary>How these records work</summary>
|
||||
<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>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>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>
|
||||
@@ -308,24 +305,18 @@
|
||||
|
||||
<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 > 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 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 .record { background: linear-gradient(125deg, color-mix(in srgb, var(--accent-primary) 16%, transparent), transparent); }
|
||||
.record strong { color: var(--accent-primary-hover); font-size: clamp(36px, 4vw, 52px); }
|
||||
.metric-help, .record-details { color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); margin-top: 4px; white-space: normal; }
|
||||
.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: var(--surface-raised); }
|
||||
.record strong { color: var(--text-primary); font-size: var(--text-2xl); }
|
||||
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); }
|
||||
.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; }
|
||||
.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; }
|
||||
label { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
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); }
|
||||
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; }
|
||||
.refresh-status { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; text-align: right; }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.table-scroll { position: relative; max-width: 100%; overflow-x: auto; }
|
||||
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); }
|
||||
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 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; }
|
||||
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
|
||||
.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; }
|
||||
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.stat.selected { color: var(--accent-primary-hover); }
|
||||
td.stat { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; }
|
||||
.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); }
|
||||
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; }
|
||||
@@ -370,6 +360,13 @@
|
||||
.tracking-notes > div { max-width: 100ch; }
|
||||
.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; }
|
||||
.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: 800px) {
|
||||
.overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -382,10 +379,20 @@
|
||||
.page { gap: var(--space-4); }
|
||||
.filters { gap: var(--space-3); }
|
||||
.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); }
|
||||
.refresh-status { text-align: left; }
|
||||
.player { min-width: 180px; max-width: 220px; }
|
||||
th, td { padding: var(--space-3); }
|
||||
.pagination { padding: var(--space-4); }
|
||||
}
|
||||
|
||||
@@ -2,19 +2,23 @@ import { error } from '@sveltejs/kit';
|
||||
import { playerReport } from '$lib/server/reports/index.js';
|
||||
import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.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 }) {
|
||||
const game = url.searchParams.get('game');
|
||||
const map = url.searchParams.get('map');
|
||||
const requestedMetric = url.searchParams.get('metric');
|
||||
const players = url.searchParams.get('players');
|
||||
if ((game !== null && game !== 't5' && game !== 't6') || (map !== null && map.length > 1024)
|
||||
|| (requestedMetric !== null && !isLeaderboardMetric(requestedMetric))) {
|
||||
error(400, 'Use game t5 or t6, a supported metric, and a map key up to 1024 characters.');
|
||||
|| (requestedMetric !== null && !isLeaderboardMetric(requestedMetric))
|
||||
|| (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 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);
|
||||
if (!profile) error(404, 'No recorded stats found for this player');
|
||||
return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
|
||||
scope: { game: game ?? '', map: map ?? '', metric, entry } };
|
||||
scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry } };
|
||||
}
|
||||
|
||||
@@ -1,183 +1,158 @@
|
||||
<script lang="ts">
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
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 { 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 Button from '$ui/components/primitives/Button.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.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 PlayerMapCards from '$ui/components/PlayerMapCards.svelte';
|
||||
import PlayerMatchCards from '$ui/components/PlayerMatchCards.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
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 profile = $derived(data.profile);
|
||||
const stats = $derived(profile.stats);
|
||||
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 selectedMetric = $derived(leaderboardMetrics.find(metric => metric.key === scope.metric)!);
|
||||
const selectedValue = $derived(scope.entry ? isEfficiencyMetric(scope.metric)
|
||||
? formatEfficiencyValue(scope.entry[scope.metric], scope.metric) : number.format(scope.entry[scope.metric]) : '—');
|
||||
const selectedValue = $derived(scope.entry ? isEfficiencyMetric(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 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.map) params.set('map', scope.map);
|
||||
if (scope.players) params.set('players', scope.players);
|
||||
return `/leaderboard?${params}`;
|
||||
});
|
||||
const date = (value: string) => $timeFormat.dateTime(value);
|
||||
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly PlayerStatMetric[];
|
||||
let refreshing = $state(false);
|
||||
let refreshError = $state('');
|
||||
const records = [{ key: 'highestRound', label: 'Best round' }, { key: 'bestKills', label: 'Best kills' }, { key: 'bestScore', label: 'Best score' }] as const;
|
||||
const overviewMetrics = playerStatMetrics.filter(metric => ['kills', 'roundsSurvived', 'revives', 'gamesCompleted'].includes(metric.key));
|
||||
const otherMetrics = playerStatMetrics.filter(metric => ['headshots', 'downs', 'bestKills', 'bestScore'].includes(metric.key));
|
||||
let filterOpen = $state(false);
|
||||
let filtering = $state(false);
|
||||
let refreshError = $state('');
|
||||
let updatedAt = $state<number | null>(null);
|
||||
let pendingUrl: URL | undefined;
|
||||
let navigationId = 0;
|
||||
async function updateScope(changes: Record<string, string>) {
|
||||
const url = new URL(profileViewHref(pendingUrl ?? $page.url, changes), $page.url);
|
||||
pendingUrl = url;
|
||||
const id = ++navigationId;
|
||||
filtering = true;
|
||||
refreshError = '';
|
||||
try { await goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true }); }
|
||||
catch { refreshError = 'Unable to update the player filters. Please try again.'; }
|
||||
filtering = true; refreshError = '';
|
||||
try { await goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true }); updatedAt = Date.now(); }
|
||||
catch { refreshError = 'Could not update player filters.'; }
|
||||
finally { if (id === navigationId) { pendingUrl = undefined; filtering = false; } }
|
||||
}
|
||||
async function refresh() {
|
||||
refreshing = true;
|
||||
refreshError = '';
|
||||
try { await invalidateAll(); }
|
||||
catch { refreshError = 'Unable to refresh player stats. Please try again.'; }
|
||||
finally { refreshing = false; }
|
||||
if (filtering) return;
|
||||
try { await invalidateAll(); updatedAt = Date.now(); refreshError = ''; }
|
||||
catch { refreshError = 'Player updates paused.'; return false; }
|
||||
}
|
||||
onMount(() => { updatedAt = Date.now(); return pollVisible(refresh, 30_000, false); });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{profile.name} · Player stats · Electric Cherry</title></svelte:head>
|
||||
|
||||
<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 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>
|
||||
<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>
|
||||
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
|
||||
|
||||
<section class="panel selection" aria-labelledby="selection-title" aria-busy={filtering}>
|
||||
<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="scope-controls">
|
||||
<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>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>
|
||||
</div>
|
||||
<div class="scope-rank">
|
||||
{#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>
|
||||
{: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>
|
||||
<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>
|
||||
<section id="overview" aria-labelledby="overview-title">
|
||||
<h2 id="overview-title" class="sr-only">Player overview</h2>
|
||||
<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="hero-stats">
|
||||
<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 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="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 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><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>Completed matches in the selected game and map, most recently observed first. Stats reflect this player's participation.</p></div>
|
||||
<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} /></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}
|
||||
<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>
|
||||
<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 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>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>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>
|
||||
{#snippet actions()}<Button onclick={() => filterOpen = false}>Done</Button>{/snippet}
|
||||
</Dialog>
|
||||
<style>
|
||||
.profile { display: flex; flex-direction: column; gap: var(--space-6); 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-3); scroll-margin-top: 6rem; }
|
||||
.profile { display: flex; flex-direction: column; gap: var(--space-5); min-width: 0; }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-2); scroll-margin-top: 6rem; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.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); }
|
||||
.stat span { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.stat strong { font-family: var(--font-mono); font-size: var(--text-2xl); }
|
||||
.record { border-color: var(--accent-primary); }
|
||||
.record strong, .best { color: var(--accent-primary-hover); }
|
||||
.metrics strong { font-size: var(--text-xl); }
|
||||
.panel { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
|
||||
.panel-heading { padding: var(--space-5); }
|
||||
.panel-heading p, .note { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
|
||||
.scope-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); padding: 0 var(--space-5) var(--space-4); }
|
||||
.scope-controls label { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.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; }
|
||||
.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); }
|
||||
.scope-rank p { margin: 0; }
|
||||
section > .note { margin-top: var(--space-3); }
|
||||
.mobile-records { display: none; }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
.jump-links { display: flex; flex-wrap: wrap; gap: var(--space-4); font-size: var(--text-sm); }
|
||||
.jump-links a { display: inline-flex; align-items: center; min-height: 44px; }
|
||||
.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); }
|
||||
.scope-summary p { margin: var(--space-1) 0 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.hero-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-3); margin-top: var(--space-3); }
|
||||
.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); }
|
||||
.stat span, .stat a, .note, small { font-size: var(--text-xs); }
|
||||
.stat span, small { color: var(--text-secondary); }
|
||||
.stat strong { font-family: var(--font-numeric); font-variant-numeric: tabular-nums; font-size: var(--text-2xl); }
|
||||
.compact strong { font-size: var(--text-xl); }
|
||||
.panel { border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
|
||||
.panel-heading { padding: var(--space-4); }
|
||||
.panel-heading p, .note { color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); }
|
||||
.panel-heading p { margin: 0; }
|
||||
.table-scroll { position: relative; max-width: 100%; overflow-x: auto; }
|
||||
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); }
|
||||
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; }
|
||||
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); font-weight: normal; }
|
||||
.status { text-transform: capitalize; }
|
||||
small { display: block; font-weight: normal; margin-top: var(--space-1); }
|
||||
.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); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
@media (max-width: 1000px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@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; } }
|
||||
.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); }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
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>
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Match History · Electric Cherry</title></svelte:head>
|
||||
<svelte:head><title>Matches · Electric Cherry</title></svelte:head>
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,11 +22,22 @@ describe('leaderboard profile navigation', () => {
|
||||
expect(next.searchParams.get('map')).toBe('t5:theater');
|
||||
const filtered = new URL(profileViewHref(current, { game: 't6', map: '' }), current);
|
||||
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('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', () => {
|
||||
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');
|
||||
|
||||
@@ -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 { 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/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 () => {
|
||||
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 data = await load({ url });
|
||||
const requested = vi.mocked(GET).mock.calls[0][0].url;
|
||||
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(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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -17,14 +17,16 @@ function scope(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; }
|
||||
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 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');
|
||||
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).toContain('26–26 of 60 players');
|
||||
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(body).not.toMatch(/<details[^>]*open/);
|
||||
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>');
|
||||
});
|
||||
|
||||
@@ -43,7 +50,7 @@ it('deduplicates the selected metric and preserves scope and pagination on playe
|
||||
const data = report(); data.metric = 'kills';
|
||||
const body = html(data);
|
||||
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');
|
||||
scope('?game=t5&map=t5%3Atheater&metric=highestRound&q=Cherry&offset=25');
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
scope('?metric=headshotRate');
|
||||
const data = report(); data.metric = 'headshotRate';
|
||||
|
||||
@@ -15,7 +15,7 @@ beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-
|
||||
afterEach(() => vi.useRealTimers());
|
||||
it('renders one page heading, six columns, a combined identity, and compact dates', () => {
|
||||
const body = render(MatchHistoryPage, { props: { data: { history: report(), user: null, setupRequired: false } } }).body;
|
||||
expect(body.match(/<h[12][ >]/g)).toHaveLength(1);
|
||||
expect(body.match(/<h1[ >]/g)).toHaveLength(1);
|
||||
expect(body.match(/scope="col"/g)).toHaveLength(6);
|
||||
expect(body).toContain('Today, 1:26 PM'); expect(body).toContain('Lobby 1 · Black Ops II');
|
||||
expect(body).not.toContain('>Server</th>'); expect(body).not.toContain('Show 25 more');
|
||||
@@ -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('matchSort%3Dround'); expect(body).toContain('Historical instance');
|
||||
expect(body).not.toContain('href="/servers/'); expect(body).not.toContain('>Deleted<');
|
||||
expect(body).toContain('26–26 of 40 matches'); expect(body).toContain('More filters (1)');
|
||||
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', () => {
|
||||
const data = report(); data.matches = [{ ...match, status: 'interrupted', finalRound: null, durationMs: null, uniquePlayers: 0 }];
|
||||
|
||||
Reference in New Issue
Block a user