Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
File diff suppressed because it is too large Load Diff
@@ -9,16 +9,36 @@ export async function load({ params, url }: { params: { guid: string }; url: URL
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))
|| (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.');
if (
(game !== null && game !== 't5' && game !== 't6') ||
(map !== null && map.length > 1024) ||
(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,
players: players === null ? undefined : Number(players) as LeaderboardPlayerCount, metric };
const { profile, entry } = await playerReport(params.guid, Number(url.searchParams.get('offset') ?? 0), options);
const metric =
requestedMetric !== null && isLeaderboardMetric(requestedMetric)
? requestedMetric
: 'highestRound';
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 ?? '', players: players ?? '', metric, entry } };
return {
profile,
returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
scope: { game: game ?? '', map: map ?? '', players: players ?? '', metric, entry }
};
}
@@ -5,8 +5,22 @@
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
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 {
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';
@@ -22,28 +36,65 @@
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; players?: string; metric: LeaderboardMetric; entry: LeaderboardEntry | null } } } = $props();
let {
data
}: {
data: {
profile: PlayerProfile;
returnTo: string;
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 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 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 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, map: scope.map });
if (scope.game) params.set('game', scope.game);
if (scope.players) params.set('players', scope.players);
return `/leaderboard?${params}`;
});
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));
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('');
@@ -54,106 +105,544 @@
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 }); updatedAt = Date.now(); }
catch { refreshError = 'Could not update player filters.'; }
finally { if (id === navigationId) { pendingUrl = undefined; filtering = false; } }
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() {
if (filtering) return;
try { await invalidateAll(); updatedAt = Date.now(); refreshError = ''; }
catch { refreshError = 'Player updates paused.'; return false; }
try {
await invalidateAll();
updatedAt = Date.now();
refreshError = '';
} catch {
refreshError = 'Player updates paused.';
return false;
}
}
onMount(() => { updatedAt = Date.now(); return pollVisible(refresh, 30_000, 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="Lifetime Zombies stats and completed matches">
{#snippet crumbs()}<Breadcrumbs fallback={data.returnTo} items={[{ label: 'Leaderboard', href: '/leaderboard' }, { label: profile.name }]} />{/snippet}
{#snippet actions()}<Freshness {updatedAt} error={refreshError} intervalLabel="every 30 s" onRetry={() => void refresh()} />{/snippet}
<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()}<Freshness
{updatedAt}
error={refreshError}
intervalLabel="every 30 s"
onRetry={() => void refresh()}
/>{/snippet}
</SectionHeader>
<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>
<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 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>
{#if isEfficiencyMetric(scope.metric)}<p class="note">Ranking requires {efficiencyRequirement(scope.metric).toLowerCase()} in the selection.</p>{/if}
<div class="stats">{#each overviewMetrics as metric}<div class="stat compact"><span>Lifetime {metric.label.toLowerCase()}</span><strong>{number.format(stats[metric.key])}</strong></div>{/each}</div>
</section>
<section class="panel" aria-labelledby="maps-title">
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Lifetime personal bests across all squad sizes, ranked against other players on each map. Tied records share a rank. Each record links to the match that established it.</p></div>
<div class="mobile-records"><PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} /></div>
<div class="panel-heading">
<h2 id="maps-title">Per-map records</h2>
<p>
Lifetime personal bests across all squad sizes, ranked against other players on each
map. Tied records share a rank. Each record links to the match that established it.
</p>
</div>
<div class="mobile-records">
<PlayerMapCards maps={profile.maps} metric={scope.metric} {returnTo} />
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable records for keyboard users.) -->
<div class="table-scroll desktop-records" role="region" aria-label="Per-map player records" tabindex="0">
<table><thead><tr><th scope="col">Map</th>{#each records as record}<th scope="col">{record.label}</th>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th scope="col">Total rounds</th></tr></thead>
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small></th>{#each records as record}<td><a href={contextHref(`/matches/${map.records[record.key].matchId}`, $page.url)} aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`} title={map.records[record.key].endedAt ? $timeFormat.dateTime(map.records[record.key].endedAt!) : 'Completed match'}>{number.format(map[record.key])}</a><small aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}>Rank #{number.format(map.ranks[record.key])}</small></td>{/each}<td>{number.format(map.gamesPlayed)}</td><td>{number.format(map.kills)}</td><td>{number.format(map.roundsSurvived)}</td></tr>{/each}</tbody>
<div
class="table-scroll desktop-records"
role="region"
aria-label="Per-map player records"
tabindex="0"
>
<table>
<thead
><tr
><th scope="col">Map</th>{#each records as record}<th scope="col"
>{record.label}</th
>{/each}<th scope="col">Games</th><th scope="col">Total kills</th><th
scope="col">Total rounds</th
></tr
></thead
>
<tbody
>{#each profile.maps as map (`${map.game}:${map.key}`)}<tr
><th scope="row"
><a
href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}
>{map.name}</a
><small>{gameName(map.game)}</small></th
>{#each records as record}<td
><a
href={contextHref(
`/matches/${map.records[record.key].matchId}`,
$page.url
)}
aria-label={`${map.name} ${record.label}: ${number.format(map[record.key])}, Match #${map.records[record.key].matchId}`}
title={map.records[record.key].endedAt
? $timeFormat.dateTime(map.records[record.key].endedAt!)
: 'Completed match'}>{number.format(map[record.key])}</a
><small
aria-label={`${map.name} ${record.label} rank ${map.ranks[record.key]}`}
>Rank #{number.format(map.ranks[record.key])}</small
></td
>{/each}<td>{number.format(map.gamesPlayed)}</td><td
>{number.format(map.kills)}</td
><td>{number.format(map.roundsSurvived)}</td></tr
>{/each}</tbody
>
</table>
</div>
</section>
<section class="panel" aria-labelledby="history-title">
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>{scopeLabel}. Stats reflect this player's participation.</p></div>
<div class="panel-heading">
<h2 id="history-title">Match history</h2>
<p>{scopeLabel}. Stats reflect this player's participation.</p>
</div>
{#if profile.matches.length}
<div class="mobile-records"><PlayerMatchCards matches={profile.matches} {returnTo} /></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable match history for keyboard users.) -->
<div class="table-scroll desktop-records" role="region" aria-label="Player match history" tabindex="0">
<table><thead><tr><th scope="col">Match / server</th><th scope="col">First observed</th><th scope="col">Round</th><th scope="col">Kills</th><th scope="col">Revives</th><th scope="col">Downs</th><th scope="col">Final report</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><RelativeTime value={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>{playerCompletionLabel(match)}</td></tr>{/each}</tbody></table>
<div class="mobile-records">
<PlayerMatchCards matches={profile.matches} {returnTo} />
</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}
<!-- 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">Final report</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><RelativeTime value={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
>{playerCompletionLabel(match)}</td
></tr
>{/each}</tbody
>
</table>
</div>
<div class="pagination">
<span
>{number.format(profile.offset + 1)}–{number.format(
profile.offset + profile.matches.length
)} of {number.format(profile.matchesTotal)}
{profile.matchesTotal === 1 ? 'game' : 'games'}</span
>
<div>
<Button
size="sm"
href={`${profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })}#history-title`}
disabled={profile.offset === 0}>Previous</Button
><Button
size="sm"
href={`${profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })}#history-title`}
disabled={profile.offset + profile.limit >= profile.matchesTotal}
>Next</Button
>
</div>
</div>
{:else}<EmptyState
headingLevel={3}
title="No completed matches in this selection"
description="Choose another game, map, or squad size to explore this player's history."
>{#snippet actions()}<Button
size="sm"
onclick={() => void updateScope({ game: '', map: '', players: '' })}
>Show all completed matches</Button
>{/snippet}</EmptyState
>{/if}
</section>
<section class="panel" aria-labelledby="metrics-title"><div class="panel-heading"><h2 id="metrics-title">Lifetime statistics</h2><p>All games, maps and squad sizes. Only completed matches contribute.</p></div><dl class="metric-list">{#each otherMetrics as metric}<div><dt>{metric.label}</dt><dd>{number.format(stats[metric.key])}</dd></div>{/each}{#each efficiencyMetrics as metric}<div><dt>{metric.label}</dt><dd>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</dd><small>{efficiencySample(stats, metric.key)}</small><small>{efficiencyEligible(stats, metric.key) ? 'Qualified for lifetime ranking' : `${efficiencyRequirement(metric.key)} to rank`}</small></div>{/each}</dl></section>
<details class="note"><summary>Identity and how these stats work</summary><div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen <RelativeTime value={profile.firstSeen} /></span><span>Last seen <RelativeTime value={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. Games completed counts matches where the player appears in the final report; it does not indicate a win or uninterrupted survival. Records follow the same GUID across name changes. Recorded presence does not establish continuous survival.</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 <RelativeTime value={profile.firstSeen} /></span
><span>Last seen <RelativeTime value={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.
Games completed counts matches where the player appears in the final report; it does not
indicate a win or uninterrupted survival. 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.">
<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>
<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}
{#snippet actions()}<Button onclick={() => (filterOpen = false)}>Done</Button>{/snippet}
</Dialog>
<style>
.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); }
.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-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: 180px; white-space: normal; overflow-wrap: anywhere; }
td { font-variant-numeric: tabular-nums; }
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); }
.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; } }
.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);
}
.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-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: 180px;
white-space: normal;
overflow-wrap: anywhere;
}
td {
font-variant-numeric: tabular-nums;
}
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);
}
.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>