Focus player records and add squad leaderboards
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user