Add player profiles with lifetime stats and map records

This commit is contained in:
2026-09-19 22:24:35 -04:00
parent ce0321a6e9
commit 33f2f123c5
7 changed files with 268 additions and 2 deletions
+2 -2
View File
@@ -2,7 +2,7 @@
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { leaderboardMetrics, type LeaderboardMetric, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import { leaderboardMetrics, playerProfileHref, type LeaderboardMetric, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
@@ -208,7 +208,7 @@
<tr>
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
<th scope="row" class="player">
<span class="player-name">{entry.name}</span>
<a class="player-name" href={playerProfileHref(entry.game, entry.guid)}>{entry.name}</a>
<div class="identity"><span class="game-label">{entry.game.toUpperCase()}</span><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
</th>
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}</td>{/each}
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js';
export function load({ params, url }: { params: { game: string; guid: string }; url: URL }) {
if (params.game !== 't5' && params.game !== 't6') error(404, 'Player not found');
const profile = getPlayerProfile(params.game, params.guid, Number(url.searchParams.get('offset') ?? 0));
if (!profile) error(404, 'No recorded stats found for this player');
return { profile };
}
@@ -0,0 +1,124 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import CopyValue from '$ui/components/CopyValue.svelte';
let { data }: { data: { profile: PlayerProfile } } = $props();
const profile = $derived(data.profile);
const stats = $derived(profile.stats);
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
const date = (value: string) => new Date(value).toLocaleString('en-US', { timeZone: 'UTC' }) + ' UTC';
const ratio = (value: number, total: number, percent = false) => total ? `${number.format(value / total * (percent ? 100 : 1))}${percent ? '%' : ''}` : '—';
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[];
let refreshing = $state(false);
let refreshError = $state('');
async function refresh() {
refreshing = true;
refreshError = '';
try { await invalidateAll(); }
catch { refreshError = 'Unable to refresh player stats. Please try again.'; }
finally { refreshing = 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={`${profile.game === 't5' ? 'Black Ops I' : 'Black Ops II'} · All recorded games`}>
{#snippet crumbs()}<a href="/leaderboard">← 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 aria-labelledby="totals-title">
<h2 id="totals-title">Lifetime stats</h2>
<div class="stats">
{#each leaderboardMetrics as metric (metric.key)}
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong></div>
{/each}
</div>
</section>
<section aria-labelledby="metrics-title">
<h2 id="metrics-title">Player metrics</h2>
<div class="stats metrics">
<div class="stat"><span>Headshots / kills</span><strong>{ratio(stats.headshots, stats.kills, true)}</strong></div>
<div class="stat"><span>Kills per down</span><strong>{ratio(stats.kills, stats.downs)}</strong></div>
<div class="stat"><span>Average kills per game</span><strong>{ratio(stats.kills, stats.gamesPlayed)}</strong></div>
<div class="stat"><span>Average rounds per game</span><strong>{ratio(stats.roundsSurvived, stats.gamesPlayed)}</strong></div>
<div class="stat"><span>Completion rate</span><strong>{ratio(stats.gamesCompleted, stats.gamesPlayed, true)}</strong></div>
</div>
<p class="note">Averages include games in progress. A dash means there is no denominator for that metric.</p>
</section>
<section class="panel" aria-labelledby="maps-title">
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Personal best rounds and every tracked stat, ordered by highest round.</p></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll" role="region" aria-label="Per-map player records" tabindex="0">
<table>
<thead><tr><th scope="col">Map</th>{#each leaderboardMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
<tbody>{#each profile.maps as map (map.key)}
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: profile.game, map: map.key })}`}>{map.name}</a><small>Last played {date(map.lastSeen)}</small></th>
{#each leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}</td>{/each}</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>Most recently observed games first. Stats reflect this player's participation.</p></div>
<!-- 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>
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{leaderboardMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
<tbody>{#each profile.matches as match (match.id)}
<tr><th scope="row">{match.mapName}<small>{match.server}</small></th><td>{date(match.startedAt)}</td>
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
{#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each}
</tr>
{/each}</tbody>
</table>
</div>
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(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>
</section>
<details class="note"><summary>How these stats work</summary>
<p>Records cover saved telemetry, including active and interrupted games. Highest round includes mid-game joins; total rounds only counts observed round progression. Kills, headshots, revives and downs use each match's highest reported counters.</p>
<p>Highest reported score is the largest observed points balance, not total points earned. Completion requires the player to appear in the final report. Records stay with the same GUID within this game when a player changes names.</p>
</details>
</section>
<style>
.profile { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; }
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); }
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
a { color: var(--accent-primary-hover); }
.stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); }
.stat { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
.stat span { color: var(--text-secondary); font-size: var(--text-xs); }
.stat strong { font-family: var(--font-mono); font-size: var(--text-2xl); }
.record { border-color: var(--accent-primary); }
.record strong, .best { color: var(--accent-primary-hover); }
.metrics strong { font-size: var(--text-xl); }
.panel { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.panel-heading { padding: var(--space-5); }
.panel-heading p, .note { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
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); }
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; }
thead th { background: var(--surface-sunken); color: var(--text-secondary); font-size: var(--text-xs); }
th:first-child { text-align: left; min-width: 200px; white-space: normal; overflow-wrap: anywhere; }
td { font-variant-numeric: tabular-nums; }
small { display: block; margin-top: 4px; font-size: var(--text-xs); color: var(--text-tertiary); font-weight: normal; }
.status { text-transform: capitalize; }
.pagination, .pagination > div { display: flex; gap: var(--space-3); align-items: center; }
.pagination { padding: var(--space-4); justify-content: space-between; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); }
summary { cursor: pointer; color: var(--text-secondary); }
@media (max-width: 1000px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 640px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } }
</style>