Files
electric-cherry/src/routes/leaderboard/+page.svelte
T

335 lines
23 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
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';
import CopyValue from '$ui/components/CopyValue.svelte';
const number = new Intl.NumberFormat('en-US');
const pageSize = 50;
const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }];
const defaultColumns: LeaderboardMetric[] = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed'];
const descriptions: Record<LeaderboardMetric, string> = {
highestRound: 'The highest round reached in a recorded game.',
kills: 'Cumulative kills across recorded games.',
roundsSurvived: 'Round progression during observed participation, added across games.',
headshots: 'Cumulative headshots across recorded games.',
revives: 'Cumulative teammates revived across recorded games.',
downs: 'Cumulative downs across recorded games, ranked from most to fewest.',
gamesPlayed: 'Games with recorded player stats, including games in progress.',
gamesCompleted: 'Games where the player appears in the final game-end report.',
bestKills: 'The most kills reported for a player in a single game.',
bestScore: 'The highest reported points balance. Spending points reduces this balance; it is not total points earned.'
};
let data = $state<LeaderboardResponse | null>(null);
let maps = $state<LeaderboardMap[]>([]);
let loading = $state(true);
let error = $state('');
let search = $state('');
let mounted = $state(false);
let checkedAt = $state<Date | null>(null);
let totalsOpen = $state(true);
let fullTable = $state(false);
let controller: AbortController | undefined;
let requestId = 0;
let loadedUrl = '';
let pendingUrl: URL | undefined;
let navigationId = 0;
const game = $derived(games.some(item => item.key === $page.url.searchParams.get('game')) ? $page.url.searchParams.get('game')! : '');
const map = $derived($page.url.searchParams.get('map') ?? '');
const query = $derived(($page.url.searchParams.get('q') ?? '').trim().slice(0, 100));
const metric = $derived(leaderboardMetrics.find(item => item.key === $page.url.searchParams.get('metric'))?.key ?? 'highestRound');
const offset = $derived(Math.max(0, Math.min(1_000_000, Math.floor(Number($page.url.searchParams.get('offset')) || 0))));
const filteredMaps = $derived(maps.filter(item => !game || item.game === game));
const mapName = $derived(maps.find(item => item.key === map)?.name);
const scope = $derived(mapName ?? (game ? games.find(item => item.key === game)!.label : 'All games & maps'));
const metricLabel = $derived(leaderboardMetrics.find(item => item.key === metric)!.label);
const columns = $derived([metric, ...defaultColumns.filter(key => key !== metric)]);
const apiUrl = $derived.by(() => {
const params = new URLSearchParams({ metric, limit: String(pageSize), offset: String(offset) });
if (game) params.set('game', game);
if (map) params.set('map', map);
if (query) params.set('q', query);
return `/api/leaderboard?${params}`;
});
$effect(() => { search = query; });
$effect(() => {
const url = apiUrl;
if (mounted) untrack(() => { void refresh(url); });
});
onMount(() => {
totalsOpen = !window.matchMedia('(max-width: 640px)').matches;
mounted = true;
const refreshVisible = () => { if (!document.hidden && !loading) void refresh(apiUrl); };
const interval = setInterval(refreshVisible, 15_000);
document.addEventListener('visibilitychange', refreshVisible);
return () => {
++requestId;
controller?.abort();
clearInterval(interval);
document.removeEventListener('visibilitychange', refreshVisible);
};
});
async function refresh(url: string) {
const id = ++requestId;
controller?.abort();
const activeController = new AbortController();
controller = activeController;
if (url !== loadedUrl) { data = null; checkedAt = null; }
loading = true;
error = '';
const timeout = setTimeout(() => activeController.abort(), 20_000);
try {
const response = await fetch(url, { signal: activeController.signal, cache: 'no-store' });
if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`);
const result: LeaderboardResponse = await response.json();
if (id !== requestId) return;
data = result;
maps = result.maps;
loadedUrl = url;
checkedAt = new Date();
} catch (cause) {
if (id !== requestId) return;
error = activeController.signal.aborted ? 'The leaderboard request timed out.' : cause instanceof Error ? cause.message : 'Unable to load the leaderboard.';
} finally {
clearTimeout(timeout);
if (id === requestId) loading = false;
}
}
function updateFilters(changes: Record<string, string>) {
// Keep rapid changes together while the preceding navigation settles.
const url = new URL(pendingUrl ?? $page.url);
url.searchParams.delete('offset');
for (const [key, value] of Object.entries(changes)) {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
}
pendingUrl = url;
const id = ++navigationId;
void goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true })
.catch(() => { error = 'Unable to update the leaderboard filters. Please try again.'; })
.finally(() => { if (id === navigationId) pendingUrl = undefined; });
}
function clearFilters() {
search = '';
updateFilters({ game: '', map: '', q: '' });
}
</script>
<svelte:head>
<title>Leaderboard · Electric Cherry</title>
<meta name="description" content="Player records and lifetime Zombies stats across your Electric Cherry fleet." />
</svelte:head>
<section class="page">
<SectionHeader title="Leaderboard" eyebrow="Every round counts" subtitle="Personal bests and lifetime stats, across the fleet.">
{#snippet actions()}
<Button size="sm" loading={loading} onclick={() => void refresh(apiUrl)}>Refresh</Button>
{/snippet}
</SectionHeader>
<div class="filters">
<div class="field"><label for="leaderboard-game">Game</label>
<select id="leaderboard-game" value={game} onchange={event => updateFilters({ game: event.currentTarget.value, map: '' })}>
<option value="">All games</option>
{#each games as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select>
</div>
<div class="field"><label for="leaderboard-map">Map</label>
<select id="leaderboard-map" value={map} onchange={event => updateFilters({ map: event.currentTarget.value })}>
<option value="">All maps</option>
{#if map && !filteredMaps.some(item => item.key === map)}<option value={map}>{map}</option>{/if}
{#each filteredMaps as item (item.key)}<option value={item.key}>{item.name}{!game ? ` · ${item.game.toUpperCase()}` : ''}</option>{/each}
</select>
</div>
<div class="field"><label for="leaderboard-metric">Rank by</label>
<select id="leaderboard-metric" value={metric} onchange={event => updateFilters({ metric: event.currentTarget.value })}>
{#each leaderboardMetrics as item (item.key)}<option value={item.key}>{item.label}</option>{/each}
</select>
</div>
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); }} role="search">
<label for="leaderboard-search">Find a player</label>
<div><input id="leaderboard-search" type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" /><Button type="submit" size="sm">Search</Button></div>
</form>
</div>
<div class="scope-row">
<div><span class="scope">{scope}</span><span class="all-time">All time</span></div>
{#if game || map || query}<button class="text-button" onclick={clearFilters}>Clear filters</button>{/if}
</div>
{#if error}
<div class="error" role="alert"><span>{error}{data ? ' Showing the last successful update.' : ''}</span><Button size="sm" onclick={() => void refresh(apiUrl)}>Try again</Button></div>
{/if}
{#if data}
<details class="totals" bind:open={totalsOpen}><summary>Fleet totals · {number.format(data.summary.players)} players · Round {number.format(data.summary.highestRound)} record</summary>
<div class="overview" aria-label="Totals for the selected game and map">
<div class="record"><span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong><small>Fleet record</small></div>
<div><span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small>Across all players</small></div>
<div><span>Total rounds</span><strong>{number.format(data.summary.roundsSurvived)}</strong><small>Total player rounds</small></div>
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Includes active games</small></div>
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Across tracked games</small></div>
</div>
</details>
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
<div class="rankings-header">
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
</div>
{#if data.entries.length}
<div class="mobile-view"><button class="text-button" aria-pressed={fullTable} onclick={() => fullTable = !fullTable}>{fullTable ? 'Show selected stat only' : 'Show all stats and player IDs'}</button></div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
<div class="table-scroll" role="region" aria-label="Player rankings" tabindex="0">
<table>
<caption class="sr-only">{scope}: players ranked by {metricLabel.toLowerCase()}, highest first</caption>
<thead><tr>
<th scope="col" class="rank">Rank</th>
<th scope="col" class="player">Player</th>
{#each columns as key (key)}
<th scope="col" class="stat" class:selected={key === metric} aria-sort={key === metric ? 'descending' : 'none'}>
<button onclick={() => updateFilters({ metric: key })} title={descriptions[key]}>{leaderboardMetrics.find(item => item.key === key)!.label}{#if key === metric}<span aria-hidden="true"> ↓</span>{/if}</button>
</th>
{/each}
</tr></thead>
<tbody>
{#each data.entries as entry (`${entry.game}:${entry.guid}`)}
<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.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}
</tr>
{/each}
</tbody>
</table>
</div>
<div class="pagination">
<span>{number.format(data.offset + 1)}–{number.format(data.offset + data.entries.length)} of {number.format(data.total)} {data.total === 1 ? 'player' : 'players'}{query ? ` matching “${query}”` : ''}</span>
<div><Button size="sm" disabled={data.offset === 0 || loading} onclick={() => updateFilters({ offset: String(Math.max(0, offset - pageSize)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.entries.length >= data.total || loading} onclick={() => updateFilters({ offset: String(offset + pageSize) })}>Next</Button></div>
</div>
{:else if query || game || map || offset}
<EmptyState spacious title="No players found" description="Try a different game, map, or player name.">
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet}
</EmptyState>
{:else}
<EmptyState spacious title="Your first records start here" description="Player stats will appear as your servers report games and rounds. Active games count toward the leaderboard." />
{/if}
</section>
{:else if loading}
<div class="loading" role="status">Loading player records…</div>
{/if}
<details class="tracking-notes">
<summary>How these records work</summary>
<div>
<p>Records include active games and completed games. Filters apply to totals and rankings; player search only filters the rankings. Players are identified by their GUID within each game, so name changes keep their records together.</p>
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
</div>
</details>
</section>
<style>
.totals summary { cursor: pointer; padding: 10px 0; font-size: var(--text-sm); color: var(--text-secondary); }
.mobile-view { display: none; }
.page { display: flex; flex-direction: column; gap: var(--space-5); }
.filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); padding: var(--space-5); background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); }
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
label { color: var(--text-secondary); font-size: var(--text-xs); }
input, select, button { font: inherit; }
input, select { min-height: 42px; width: 100%; padding: 9px 12px; background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-md); color: var(--text-primary); font-size: var(--text-sm); }
input::placeholder { color: var(--text-tertiary); }
.search > div { display: flex; gap: var(--space-2); }
.search input { flex: 1; min-width: 0; width: 0; }
.search :global(button) { flex-shrink: 0; white-space: nowrap; }
.scope-row, .scope-row > div { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.scope-row { justify-content: space-between; }
.scope { font-weight: var(--weight-semibold); }
.all-time { font-size: var(--text-xs); color: var(--text-tertiary); padding-left: var(--space-3); border-left: 1px solid var(--border-strong); }
.text-button { border: 0; background: transparent; color: var(--accent-primary-hover); cursor: pointer; padding: 4px 0; font-size: var(--text-xs); }
.overview { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.overview > div { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-2); border-right: 1px solid var(--border-subtle); }
.overview > div:last-child { border-right: 0; }
.overview span, .overview small { color: var(--text-tertiary); font-size: var(--text-xs); }
.overview strong { font-size: clamp(21px, 2.4vw, 32px); line-height: 1.2; font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; letter-spacing: -.035em; overflow-wrap: anywhere; }
.overview .record { background: linear-gradient(125deg, color-mix(in srgb, var(--accent-primary) 15%, transparent), transparent); }
.record strong { color: var(--accent-primary-hover); }
.rankings { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.rankings-header { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); padding: var(--space-5); }
h2 { margin: 0 0 var(--space-1); font-size: var(--text-lg); letter-spacing: var(--tracking-tight); }
.rankings-header p { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); max-width: 70ch; }
.refresh-status { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; text-align: right; }
.refresh-status span { display: block; margin-top: 4px; color: var(--text-tertiary); }
.table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); }
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); }
thead th { background: var(--surface-sunken); border-top: 1px solid var(--border-subtle); font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--text-secondary); }
.rank { width: 65px; text-align: center; color: var(--text-tertiary); font-variant-numeric: tabular-nums; }
.rank span { display: inline-grid; min-width: 30px; height: 30px; place-items: center; }
.rank .podium { border-radius: var(--radius-sm); color: var(--accent-primary-hover); background: color-mix(in srgb, var(--accent-primary) 12%, transparent); font-weight: var(--weight-semibold); }
.player { position: sticky; left: 0; z-index: 1; min-width: 225px; max-width: 320px; text-align: left; }
tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
.player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; }
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin-top: var(--space-1); color: var(--text-tertiary); font-size: var(--text-xs); }
.game-label { padding-right: var(--space-2); border-right: 1px solid var(--border-strong); }
.stat { text-align: right; white-space: nowrap; }
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.stat.selected { color: var(--accent-primary-hover); }
td.selected { font-weight: var(--weight-semibold); }
th button { display: inline-flex; gap: 6px; align-items: center; background: transparent; border: 0; padding: 3px 0; cursor: pointer; color: inherit; white-space: nowrap; }
th button:hover { color: var(--accent-primary-hover); }
tbody tr:hover > * { background: var(--surface-raised); }
tbody tr:last-child > * { border-bottom: 0; }
.pagination { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-3); border-top: 1px solid var(--border-subtle); padding: var(--space-4) var(--space-5); color: var(--text-tertiary); font-size: var(--text-xs); }
.pagination > div { display: flex; gap: var(--space-2); }
.error { display: flex; justify-content: space-between; align-items: center; gap: var(--space-3); padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--state-warning) 45%, transparent); border-radius: var(--radius-md); font-size: var(--text-sm); }
.loading { padding: var(--space-12) var(--space-5); background: var(--surface); border-radius: var(--radius-lg); text-align: center; color: var(--text-secondary); }
.tracking-notes { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
.tracking-notes summary { display: list-item; width: fit-content; cursor: pointer; color: var(--text-secondary); }
.tracking-notes > div { max-width: 100ch; }
.tracking-notes p { margin: var(--space-3) 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (max-width: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } .overview > div { padding: var(--space-4); } .overview small { display: none; } }
@media (max-width: 640px) {
.mobile-view { display: block; padding: 0 16px 12px; }
.compact .stat:not(.selected) { display: none; }
.compact .player { min-width: 110px; max-width: 160px; position: static; }
.compact .identity :global(.copy) { display: none; }
.compact .stat { white-space: normal; }
.compact .rank { width: 44px; }
.compact th, .compact td { padding: 10px 8px; }
.compact thead button { white-space: normal; text-align: right; }
.page { gap: var(--space-4); }
.filters { padding: var(--space-4); gap: var(--space-3); }
.search { grid-column: 1 / -1; }
.filters > .field:nth-child(3) { grid-column: 1 / -1; }
.overview { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.overview > div { border-bottom: 1px solid var(--border-subtle); }
.overview > div:nth-child(even) { border-right: 0; }
.overview > div:last-child { grid-column: 1 / -1; border-bottom: 0; }
.overview strong { font-size: var(--text-xl); }
.rankings-header { flex-direction: column; align-items: flex-start; padding: var(--space-4); }
.refresh-status { text-align: left; }
.refresh-status span { display: inline; margin-left: var(--space-2); }
.player { min-width: 180px; max-width: 220px; }
th, td { padding: var(--space-3); }
.pagination { padding: var(--space-4); }
}
</style>