334 lines
24 KiB
Svelte
334 lines
24 KiB
Svelte
<script lang="ts">
|
||
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 ${checkedAt.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' })}` : ''}<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}>{new Date(data.summary.firstTrackedAt).toLocaleString()}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{new Date(data.summary.updatedAt).toLocaleString()}</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>
|