Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+863
-185
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>
|
||||
|
||||
Reference in New Issue
Block a user