Preserve leaderboard context in player profiles

This commit is contained in:
2026-10-01 12:23:30 -04:00
parent 47ebe83106
commit eac9ad4fbc
10 changed files with 326 additions and 24 deletions
+44
View File
@@ -0,0 +1,44 @@
import { leaderboardMetrics } from './leaderboard.js';
const origin = 'https://electric-cherry.invalid';
const contextKeys = ['game', 'map', 'metric', 'q', 'offset'] as const;
/** Only retain a local leaderboard destination and its supported view state. */
export function leaderboardReturnHref(value: string | null | undefined): string {
if (!value?.startsWith('/leaderboard')) return '/leaderboard';
try {
const url = new URL(value, origin);
if (url.origin !== origin || url.pathname !== '/leaderboard') return '/leaderboard';
const params = new URLSearchParams();
for (const key of contextKeys) {
const entry = url.searchParams.get(key);
if (entry) params.set(key, entry);
}
return `/leaderboard${params.size ? `?${params}` : ''}`;
} catch { return '/leaderboard'; }
}
export function playerContextHref(guid: string, source: string): string {
const returnTo = leaderboardReturnHref(source);
const original = new URL(returnTo, origin).searchParams;
const params = new URLSearchParams({ returnTo });
// History pagination is independent of the originating leaderboard page.
const game = original.get('game');
const map = original.get('map');
const metric = original.get('metric');
// Match the leaderboard's displayed defaults for older or hand-edited URLs.
if (game === 't5' || game === 't6') params.set('game', game);
if (map && map.length <= 1024) params.set('map', map);
if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric);
return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`;
}
export function profileViewHref(current: URL, changes: Record<string, string>): string {
const params = new URLSearchParams(current.search);
params.delete('offset');
for (const [key, value] of Object.entries(changes)) {
if (value) params.set(key, value);
else params.delete(key);
}
return `${current.pathname}${params.size ? `?${params}` : ''}`;
}
+1
View File
@@ -67,6 +67,7 @@ export interface PlayerProfile {
records: PersonalBestRecords;
maps: PlayerMapRecord[];
matches: PlayerMatch[];
matchesTotal: number;
offset: number;
limit: number;
}
+14 -2
View File
@@ -15,6 +15,16 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
/** Rank each GUID once within the selected matches; player search keeps their actual rank. */
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
return readLeaderboard(options);
}
/** Select the exact player after ranking everyone eligible in the requested scope. */
export function getPlayerLeaderboardEntry(guid: string,
options: Pick<LeaderboardOptions, 'game' | 'map' | 'metric'> = {}): LeaderboardEntry | null {
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid).entries[0] ?? null;
}
function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): LeaderboardResponse {
const handle = db();
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50;
@@ -53,8 +63,10 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
)`;
const query = options.q?.trim() ?? '';
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
const searchedParameters = query ? [...parameters, query, query] : parameters;
const search = exactGuid !== undefined ? 'WHERE guid = ?'
: query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
const searchedParameters = exactGuid !== undefined ? [...parameters, exactGuid]
: query ? [...parameters, query, query] : parameters;
const rankedEntries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search}
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`)
.all(...searchedParameters, limit, offset) as Array<Omit<LeaderboardEntry, 'records'>>;
+14 -6
View File
@@ -1,7 +1,7 @@
import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.js';
import { getPersonalBestRecords } from './leaderboard-records.js';
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
import type { LeaderboardOptions, PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
@@ -11,7 +11,8 @@ const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.guid = ? AND mp.has_stats = 1
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`;
export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfile | null {
export function getPlayerProfile(guid: string, requestedOffset = 0,
options: Pick<LeaderboardOptions, 'game' | 'map'> = {}): PlayerProfile | null {
const handle = db();
// Keep game-specific ingestion identities, but show one identity with the latest reported name.
const player = handle.prepare(`SELECT guid, name, MIN(first_seen_at) OVER () AS firstSeen,
@@ -21,8 +22,15 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi
if (!player) return null;
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
if (!stats.gamesPlayed) return null;
const historyParameters = [guid];
let historyScope = scope;
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
if (options.map) { historyScope += ' AND m.map_key = ?'; historyParameters.push(options.map); }
const matchesTotal = options.game || options.map
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
: stats.gamesPlayed;
const limit = 25;
const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit;
const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit);
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0;
const mapStats = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name,
MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope}
@@ -35,7 +43,7 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi
m.started_at AS startedAt, mp.last_seen_at AS lastSeen, m.status, mp.completed,
mp.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived,
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore
${scope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
.all(guid, limit, offset) as PlayerMatch[];
return { ...player, stats, records, maps, matches, offset, limit };
${historyScope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
.all(...historyParameters, limit, offset) as PlayerMatch[];
return { ...player, stats, records, maps, matches, matchesTotal, offset, limit };
}
+3 -2
View File
@@ -3,7 +3,8 @@
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { leaderboardMetrics, playerProfileHref, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import { leaderboardMetrics, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import { playerContextHref } from '$lib/leaderboard-navigation.js';
import { efficiencyMetrics, isEfficiencyMetric, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
@@ -215,7 +216,7 @@
<tr>
<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={playerProfileHref(entry.guid)}>{entry.name}</a>
<a class="player-name" href={playerContextHref(entry.guid, `${$page.url.pathname}${$page.url.search}`)}>{entry.name}</a>
<div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
</th>
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{formatValue(entry, key)}{#if isEfficiencyMetric(key)}<small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small>{/if}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}<RecordSource evidence={entry.records[key]} />{/if}</td>{/each}
@@ -1,8 +1,20 @@
import { error } from '@sveltejs/kit';
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js';
import { getPlayerLeaderboardEntry, isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
import { leaderboardReturnHref } from '$lib/leaderboard-navigation.js';
export function load({ params, url }: { params: { guid: string }; url: URL }) {
const profile = getPlayerProfile(params.guid, Number(url.searchParams.get('offset') ?? 0));
const game = url.searchParams.get('game');
const map = url.searchParams.get('map');
const requestedMetric = url.searchParams.get('metric');
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.');
}
const metric = requestedMetric !== null && isLeaderboardMetric(requestedMetric) ? requestedMetric : 'highestRound';
const options = { game: game || undefined, map: map || undefined, metric };
const profile = getPlayerProfile(params.guid, Number(url.searchParams.get('offset') ?? 0), options);
if (!profile) error(404, 'No recorded stats found for this player');
return { profile };
return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
scope: { game: game ?? '', map: map ?? '', metric, entry: getPlayerLeaderboardEntry(params.guid, options) } };
}
@@ -1,23 +1,51 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { invalidateAll } from '$app/navigation';
import { playerStatMetrics, type PlayerProfile, type PlayerStatMetric } from '$lib/leaderboard.js';
import { efficiencyMetrics, efficiencyValue, efficiencyEligible, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js';
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 { efficiencyMetrics, efficiencyValue, efficiencyEligible, efficiencyRequirement, efficiencySample, formatEfficiencyValue, isEfficiencyMetric } from '$lib/leaderboard-efficiency.js';
import { profileViewHref } from '$lib/leaderboard-navigation.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import RecordSource from '$ui/components/RecordSource.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
let { data }: { data: { profile: PlayerProfile } } = $props();
let { data }: { data: { profile: PlayerProfile; returnTo: string;
scope: { game: string; map: 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 number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
const scope = $derived(data.scope);
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 scopedLeaderboard = $derived.by(() => {
const params = new URLSearchParams({ metric: scope.metric });
if (scope.game) params.set('game', scope.game);
if (scope.map) params.set('map', scope.map);
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('');
let filtering = $state(false);
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.'; }
finally { if (id === navigationId) { pendingUrl = undefined; filtering = false; } }
}
async function refresh() {
refreshing = true;
refreshError = '';
@@ -31,12 +59,27 @@
<section class="profile">
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats">
{#snippet crumbs()}<a href="/leaderboard">← Leaderboard</a>{/snippet}
{#snippet crumbs()}<a href={data.returnTo}>← Leaderboard</a>{/snippet}
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/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>
</div>
</section>
<section aria-labelledby="totals-title">
<h2 id="totals-title">Lifetime stats</h2>
<div class="stats">
@@ -63,7 +106,7 @@
<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 })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
<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>
@@ -71,7 +114,8 @@
</section>
<section class="panel" aria-labelledby="history-title">
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Completed matches from both games, most recently observed first. Stats reflect this player's participation.</p></div>
<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>
{#if profile.matches.length}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll" role="region" aria-label="Player match history" tabindex="0">
<table>
@@ -84,9 +128,10 @@
{/each}</tbody>
</table>
</div>
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(stats.gamesPlayed)} games</span>
<div><Button size="sm" href={`?offset=${Math.max(0, profile.offset - profile.limit)}`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`?offset=${profile.offset + profile.limit}`} disabled={profile.offset + profile.limit >= stats.gamesPlayed}>Next</Button></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)) })} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })} 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}
</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>
@@ -109,6 +154,11 @@
.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); }
.table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
@@ -122,5 +172,5 @@
.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) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } }
@media (max-width: 640px) { .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; } }
</style>