Simplify leaderboard rows and defer disruptive refreshes

This commit is contained in:
2026-10-06 19:12:25 -04:00
parent 01d764946c
commit 1370873cba
6 changed files with 234 additions and 70 deletions
+27
View File
@@ -0,0 +1,27 @@
import type { LeaderboardResponse } from './leaderboard.js';
export const leaderboardPageSize = 25;
/** Keep a viewed ranking stable until the reader chooses to apply new results. */
export function createLeaderboardUpdates(scope: string, current: LeaderboardResponse) {
let pending: LeaderboardResponse | null = null;
return {
reset(nextScope: string, report: LeaderboardResponse) {
scope = nextScope;
current = report;
pending = null;
},
receive(requestScope: string, report: LeaderboardResponse, inspecting: boolean) {
if (requestScope !== scope) return null;
if (JSON.stringify(current) === JSON.stringify(report)) pending = null;
else if (inspecting || pending) pending = report;
else current = report;
return { current, pending };
},
apply() {
if (pending) current = pending;
pending = null;
return current;
}
};
}
+8 -6
View File
@@ -1,23 +1,25 @@
<script lang="ts"> <script lang="ts">
import type { RecordEvidence } from '$lib/leaderboard.js'; import type { RecordEvidence } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { contextHref } from '$lib/navigation-context.js';
let { evidence }: { evidence: RecordEvidence } = $props(); let { evidence, returnTo, disclosure = true }: { evidence: RecordEvidence; returnTo?: string; disclosure?: boolean } = $props();
const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II'); const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II');
</script> </script>
<div class="record-source"> <div class="record-source">
<a href={`/matches/${evidence.matchId}`}>{evidence.mapName} · {gameName}</a> <a href={returnTo ? contextHref(`/matches/${evidence.matchId}`, returnTo) : `/matches/${evidence.matchId}`}>{evidence.mapName} · {gameName}</a>
<span>{#if evidence.endedAt}Completed <time datetime={evidence.endedAt}>{$timeFormat.dateTime(evidence.endedAt)}</time>{:else}Completed · End time unknown{/if}</span> <span>{#if evidence.endedAt}Completed <time datetime={evidence.endedAt}>{$timeFormat.dateTime(evidence.endedAt)}</time>{:else}Completed · End time unknown{/if}</span>
<details> {#snippet runDetails()}
<summary>Run details</summary>
<dl> <dl>
<div><dt>Server</dt><dd>{evidence.server}</dd></div> <div><dt>Instance</dt><dd>{evidence.server}</dd></div>
<div><dt>{evidence.startObserved ? 'Match started' : 'Match first observed'}</dt><dd><time datetime={evidence.startedAt}>{$timeFormat.dateTime(evidence.startedAt)}</time></dd></div> <div><dt>{evidence.startObserved ? 'Match started' : 'Match first observed'}</dt><dd><time datetime={evidence.startedAt}>{$timeFormat.dateTime(evidence.startedAt)}</time></dd></div>
<div><dt>Player participation</dt><dd>{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}</dd></div> <div><dt>Player participation</dt><dd>{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}</dd></div>
</dl> </dl>
<p>Recorded presence does not establish continuous survival.</p> <p>Recorded presence does not establish continuous survival.</p>
</details> {/snippet}
{#if disclosure}<details><summary>Run details</summary>{@render runDetails()}</details>
{:else}{@render runDetails()}{/if}
</div> </div>
<style> <style>
+4 -1
View File
@@ -1,9 +1,12 @@
import { GET } from '../api/leaderboard/+server.js'; import { GET } from '../api/leaderboard/+server.js';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { LeaderboardResponse } from '$lib/leaderboard.js'; import type { LeaderboardResponse } from '$lib/leaderboard.js';
import { leaderboardPageSize } from '$lib/leaderboard-updates.js';
export async function load({ url }: { url: URL }) { export async function load({ url }: { url: URL }) {
const response = await GET({ url }); const reportUrl = new URL(url);
reportUrl.searchParams.set('limit', String(leaderboardPageSize));
const response = await GET({ url: reportUrl });
const body = await response.json(); const body = await response.json();
if (!response.ok) error(response.status, body.error); if (!response.ok) error(response.status, body.error);
return { leaderboard: body as LeaderboardResponse }; return { leaderboard: body as LeaderboardResponse };
+98 -65
View File
@@ -5,7 +5,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page, navigating } from '$app/stores'; import { page, navigating } from '$app/stores';
const changingFilters = $derived($navigating?.to?.route.id === '/leaderboard'); const changingFilters = $derived($navigating?.to?.route.id === '/leaderboard');
import { leaderboardMetrics, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js'; import { leaderboardMetrics, playerStatMetrics, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import { createLeaderboardUpdates, leaderboardPageSize } from '$lib/leaderboard-updates.js';
import { playerContextHref } from '$lib/leaderboard-navigation.js'; import { playerContextHref } from '$lib/leaderboard-navigation.js';
import { efficiencyMetrics, isEfficiencyMetric, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js'; import { efficiencyMetrics, isEfficiencyMetric, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
@@ -15,9 +16,9 @@
import RecordSource from '$ui/components/RecordSource.svelte'; import RecordSource from '$ui/components/RecordSource.svelte';
const number = new Intl.NumberFormat('en-US'); const number = new Intl.NumberFormat('en-US');
const pageSize = 50; const pageSize = leaderboardPageSize;
const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }]; const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }];
const defaultColumns: PlayerStatMetric[] = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed']; const defaultColumns: PlayerStatMetric[] = ['kills', 'gamesPlayed'];
const descriptions: Record<PlayerStatMetric, string> = { const descriptions: Record<PlayerStatMetric, string> = {
highestRound: 'The highest round reached in a recorded game.', highestRound: 'The highest round reached in a recorded game.',
kills: 'Cumulative kills across recorded games.', kills: 'Cumulative kills across recorded games.',
@@ -39,11 +40,11 @@
let maps = $state<LeaderboardMap[]>(untrack(() => initial.leaderboard.maps)); let maps = $state<LeaderboardMap[]>(untrack(() => initial.leaderboard.maps));
let loading = $state(false); let loading = $state(false);
let error = $state(''); let error = $state('');
let search = $state(''); let search = $state(untrack(() => ($page.url.searchParams.get('q') ?? '').trim().slice(0, 100)));
let mounted = $state(false);
let checkedAt = $state<Date | null>(null); let checkedAt = $state<Date | null>(null);
let totalsOpen = $state(true); let pendingUpdate = $state<LeaderboardResponse | null>(null);
let fullTable = $state(false); let expandedPlayers = $state<string[]>([]);
let tableRegion = $state<HTMLDivElement>();
let controller: AbortController | undefined; let controller: AbortController | undefined;
let requestId = 0; let requestId = 0;
let loadedUrl = ''; let loadedUrl = '';
@@ -68,16 +69,20 @@
return `/api/leaderboard?${params}`; return `/api/leaderboard?${params}`;
}); });
const updates = untrack(() => createLeaderboardUpdates(apiUrl, initial.leaderboard));
$effect(() => { search = query; }); $effect(() => { search = query; });
$effect(() => { $effect(() => {
const report = initial.leaderboard; const report = initial.leaderboard;
untrack(() => { ++requestId; controller?.abort(); data = report; maps = report.maps; loadedUrl = apiUrl; loading = false; error = ''; }); untrack(() => {
++requestId; controller?.abort(); data = report; maps = report.maps;
loadedUrl = apiUrl; loading = false; error = ''; checkedAt = null;
pendingUpdate = null; expandedPlayers = []; updates.reset(apiUrl, report);
});
}); });
onMount(() => { onMount(() => {
totalsOpen = !window.matchMedia('(max-width: 640px)').matches; const refreshVisible = () => { if (!document.hidden && !loading && !changingFilters && !pendingUrl) void refresh(apiUrl); };
mounted = true;
const refreshVisible = () => { if (!document.hidden && !loading) void refresh(apiUrl); };
const interval = setInterval(refreshVisible, 15_000); const interval = setInterval(refreshVisible, 15_000);
document.addEventListener('visibilitychange', refreshVisible); document.addEventListener('visibilitychange', refreshVisible);
return () => { return () => {
@@ -101,9 +106,14 @@
const response = await fetch(url, { signal: activeController.signal, cache: 'no-store' }); const response = await fetch(url, { signal: activeController.signal, cache: 'no-store' });
if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`); if (!response.ok) throw new Error(`Leaderboard request failed (${response.status}).`);
const result: LeaderboardResponse = await response.json(); const result: LeaderboardResponse = await response.json();
if (id !== requestId) return; if (id !== requestId || url !== apiUrl || changingFilters || pendingUrl) return;
data = result; const inspecting = expandedPlayers.length > 0 || !!tableRegion?.contains(document.activeElement)
maps = result.maps; || !!tableRegion?.querySelector('details[open]');
const next = updates.receive(url, result, inspecting);
if (!next) return;
data = next.current;
maps = next.current.maps;
pendingUpdate = next.pending;
loadedUrl = url; loadedUrl = url;
checkedAt = new Date(); checkedAt = new Date();
} catch (cause) { } catch (cause) {
@@ -115,7 +125,21 @@
} }
} }
function applyUpdate() {
expandedPlayers = [];
tableRegion?.querySelectorAll('details[open]').forEach(detail => detail.removeAttribute('open'));
data = updates.apply();
maps = data.maps;
pendingUpdate = null;
}
function togglePlayer(guid: string) {
expandedPlayers = expandedPlayers.includes(guid)
? expandedPlayers.filter(value => value !== guid) : [...expandedPlayers, guid];
}
function updateFilters(changes: Record<string, string>) { function updateFilters(changes: Record<string, string>) {
++requestId; controller?.abort(); loading = false;
// Keep rapid changes together while the preceding navigation settles. // Keep rapid changes together while the preceding navigation settles.
const url = new URL(pendingUrl ?? $page.url); const url = new URL(pendingUrl ?? $page.url);
url.searchParams.delete('offset'); url.searchParams.delete('offset');
@@ -142,9 +166,9 @@
</svelte:head> </svelte:head>
<section class="page"> <section class="page">
<SectionHeader title="Leaderboard" eyebrow="Every round counts" subtitle="Lifetime Zombies stats across Black Ops I and Black Ops II."> <SectionHeader title="Leaderboard" subtitle="All-time player records.">
{#snippet actions()} {#snippet actions()}
<Button size="sm" loading={loading} onclick={() => void refresh(apiUrl)}>Refresh</Button> <Button size="sm" loading={loading} disabled={changingFilters} onclick={() => void refresh(apiUrl)}>Refresh</Button>
{/snippet} {/snippet}
</SectionHeader> </SectionHeader>
@@ -169,7 +193,7 @@
</div> </div>
<form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); }} role="search"> <form class="search" onsubmit={event => { event.preventDefault(); updateFilters({ q: search.trim() }); }} role="search">
<label for="leaderboard-search">Find a player</label> <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> <input id="leaderboard-search" type="search" bind:value={search} maxlength="100" placeholder="Name or GUID" aria-describedby="search-help" /><span id="search-help" class="sr-only">Press Enter to search. Player ranks are preserved.</span>
</form> </form>
</div> </div>
@@ -183,25 +207,26 @@
{/if} {/if}
{#if data} {#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="scope-summary">
<div class="overview" aria-label="Totals for the selected game and map"> <span>{number.format(data.summary.players)} players · {number.format(data.summary.matches)} completed games · Highest round {number.format(data.summary.highestRound)}</span>
<div class="record"><span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong><small>Fleet record</small></div> <details class="totals"><summary>Scope totals</summary>
<div><span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small>Across all players</small></div> <p>{number.format(data.summary.kills)} total kills · {number.format(data.summary.roundsSurvived)} player rounds</p>
<div><span>Total rounds</span><strong>{number.format(data.summary.roundsSurvived)}</strong><small>Total player rounds</small></div> <p>All recorded players in {scope}; player search and qualification do not change these totals.</p>
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Completed games only</small></div>
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Each player counted once</small></div>
</div>
</details> </details>
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading || changingFilters}>
<div class="rankings-header">
<div><h2 id="rankings-title">{metricLabel}</h2><p>{description(metric)}</p>{#if isEfficiencyMetric(metric)}<p>Qualification is measured within the selected game and map. Fleet totals include all recorded players.</p>{/if}</div>
<div class="refresh-status" role="status">{changingFilters ? 'Updating filters…' : loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
</div> </div>
<section class="rankings" aria-labelledby="rankings-title" aria-busy={loading || changingFilters}>
<div class="rankings-header">
<div><h2 id="rankings-title">{metricLabel}</h2>
{#if isEfficiencyMetric(metric)}<p>{efficiencyRequirement(metric)} within the selected game and map.</p>{/if}
<details class="metric-help"><summary>About this ranking</summary><p>{description(metric)} Equal values share a rank.</p></details>
</div>
<div class="refresh-status" role="status">{changingFilters ? 'Updating filters…' : loading ? 'Checking…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : 'Checks every 15 seconds'}</div>
</div>
{#if pendingUpdate}<div class="pending-update" role="status"><button class="text-button" onclick={applyUpdate} disabled={changingFilters}>Updated rankings available — apply update</button></div>{/if}
{#if data.entries.length} {#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.) --> <!-- 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"> <div class="table-scroll" bind:this={tableRegion} role="region" aria-label="Player rankings" tabindex="0">
<table> <table>
<caption class="sr-only">{scope}: players ranked by {metricLabel.toLowerCase()}, highest first</caption> <caption class="sr-only">{scope}: players ranked by {metricLabel.toLowerCase()}, highest first</caption>
<thead><tr> <thead><tr>
@@ -219,10 +244,32 @@
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td> <td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
<th scope="row" class="player"> <th scope="row" class="player">
<a class="player-name" href={playerContextHref(entry.guid, `${$page.url.pathname}${$page.url.search}`)}>{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> <button class="text-button" aria-expanded={expandedPlayers.includes(entry.guid)} aria-controls={`player-details-${entry.guid}`} onclick={() => togglePlayer(entry.guid)}>{expandedPlayers.includes(entry.guid) ? 'Hide details' : 'Details'}<span class="sr-only"> for {entry.name}</span></button>
</th> </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} {#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'}
<small class="record-map">{entry.records[key].mapName}</small>
<details class="record-details">
<summary>Record details<span class="sr-only"> for {entry.name}</span></summary>
<RecordSource evidence={entry.records[key]} disclosure={false} returnTo={`${$page.url.pathname}${$page.url.search}`} />
</details>
{/if}
</td>
{/each}
</tr> </tr>
{#if expandedPlayers.includes(entry.guid)}
<tr class="player-details" id={`player-details-${entry.guid}`}><td colspan={columns.length + 2}>
<div class="identity">GUID <CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
<dl>{#each playerStatMetrics.filter(item => !columns.includes(item.key)) as stat (stat.key)}
<div><dt>{stat.label}</dt><dd>{formatValue(entry, stat.key)}</dd></div>
{/each}</dl>
</td></tr>
{/if}
{/each} {/each}
</tbody> </tbody>
</table> </table>
@@ -260,44 +307,44 @@
</section> </section>
<style> <style>
.scope-summary { color: var(--text-secondary); font-size: var(--text-sm); }
.totals p { margin: 4px 0; font-size: var(--text-xs); }
.metric-help, .record-details { color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); margin-top: 4px; white-space: normal; }
summary { cursor: pointer; }
.metric-help p { margin-top: 8px; }
.record-map { display: block; margin-top: 4px; color: var(--text-secondary); font: normal var(--text-xs) var(--font-body); white-space: normal; }
.pending-update { padding: 0 var(--space-5) var(--space-3); }
.player-details td { background: var(--surface-sunken); }
.player-details dl { display: flex; flex-wrap: wrap; gap: 16px 32px; margin: 12px 0 0; }
.player-details dt { color: var(--text-secondary); font-size: var(--text-xs); }
.player-details dd { margin: 4px 0 0; font-variant-numeric: tabular-nums; }
.text-button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
.totals summary { cursor: pointer; padding: 10px 0; font-size: var(--text-sm); color: var(--text-secondary); } .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); } .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); } .filters { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1fr) minmax(180px, 1fr) minmax(230px, 1.25fr); gap: var(--space-4); }
.field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; } .field, .search { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
label { color: var(--text-secondary); font-size: var(--text-xs); } label { color: var(--text-secondary); font-size: var(--text-xs); }
input, select, button { font: inherit; } 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, 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); } 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, .scope-row > div { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.scope-row { justify-content: space-between; } .scope-row { justify-content: space-between; }
.scope { font-weight: var(--weight-semibold); } .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); } .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); } .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 { 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); } .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); } 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; } .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 { 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-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--text-sm); } 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); } th, td { padding: 12px 16px; 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); } 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 { 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 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); } .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; } .player { position: sticky; left: 0; z-index: 1; min-width: 180px; max-width: 320px; text-align: left; }
tbody .player { background: var(--surface); font-weight: var(--weight-medium); } tbody .player { background: var(--surface); font-weight: var(--weight-medium); }
.player-name { display: block; color: var(--text-primary); overflow-wrap: anywhere; } .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); } .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); }
@@ -319,28 +366,14 @@
.tracking-notes > div { max-width: 100ch; } .tracking-notes > div { max-width: 100ch; }
.tracking-notes p { margin: var(--space-3) 0 0; } .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; } .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: 1000px) { .filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { @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 { 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); } .page { gap: var(--space-4); }
.filters { padding: var(--space-4); gap: var(--space-3); } .filters { gap: var(--space-3); }
.search { grid-column: 1 / -1; } .search { grid-column: 1 / -1; }
.filters > .field:nth-child(3) { 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); } .rankings-header { flex-direction: column; align-items: flex-start; padding: var(--space-4); }
.refresh-status { text-align: left; } .refresh-status { text-align: left; }
.refresh-status span { display: inline; margin-left: var(--space-2); }
.player { min-width: 180px; max-width: 220px; } .player { min-width: 180px; max-width: 220px; }
th, td { padding: var(--space-3); } th, td { padding: var(--space-3); }
.pagination { padding: var(--space-4); } .pagination { padding: var(--space-4); }
+15
View File
@@ -0,0 +1,15 @@
import { expect, it, vi } from 'vitest';
import { GET } from '../../src/routes/api/leaderboard/+server.js';
import { load } from '../../src/routes/leaderboard/+page.server.js';
vi.mock('../../src/routes/api/leaderboard/+server.js', () => ({ GET: vi.fn() }));
it('requests the same 25-player page on initial load and preserves filters without mutating the page URL', async () => {
vi.mocked(GET).mockResolvedValueOnce(new Response(JSON.stringify({ entries: [], limit: 25, offset: 25 })));
const url = new URL('http://localhost/leaderboard?game=t5&map=theater&metric=kills&q=123&offset=25&limit=100');
const original = url.href;
const data = await load({ url });
const requested = vi.mocked(GET).mock.calls[0][0].url;
expect(requested.searchParams.get('limit')).toBe('25');
for (const key of ['game', 'map', 'metric', 'q', 'offset']) expect(requested.searchParams.get(key)).toBe(url.searchParams.get(key));
expect(url.href).toBe(original);
expect(data.leaderboard.limit).toBe(25);
});
+84
View File
@@ -0,0 +1,84 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable, type Writable } from 'svelte/store';
import { page } from '$app/stores';
import Leaderboard from '../../src/routes/leaderboard/+page.svelte';
import { createLeaderboardUpdates, leaderboardPageSize } from '../../src/lib/leaderboard-updates.js';
import type { LeaderboardEntry, LeaderboardResponse, RecordEvidence } from '../../src/lib/leaderboard.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/leaderboard') }), navigating: writable(null) }));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
const evidence: RecordEvidence = { matchId: 71, game: 't5', mapKey: 't5:theater', mapName: 'Kino Der Toten', server: 't5_zombie_theater_1', startedAt: '2026-10-06T12:00:00Z', endedAt: '2026-10-06T13:00:00Z', startObserved: true, presentAtStart: false, firstObservedRound: 8, value: 49 };
const entry: LeaderboardEntry = { rank: 27, guid: '12345', name: 'Cherry', highestRound: 49, kills: 1000, roundsSurvived: 60, headshots: 100, revives: 3, downs: 5, gamesPlayed: 6, gamesCompleted: 5, bestKills: 500, bestScore: 4000, lastSeen: evidence.endedAt!, records: { highestRound: evidence, bestKills: evidence, bestScore: evidence }, headshotRate: 0.1, killsPerDown: 200, averageKills: 1000 / 6, averageRounds: 10, completionRate: 5 / 6 };
function report(): LeaderboardResponse {
return { entries: [structuredClone(entry)], maps: [{ key: 't5:theater', game: 't5', map: 'theater', name: 'Kino Der Toten' }], total: 60, limit: 25, offset: 25, metric: 'highestRound', summary: { players: 473, matches: 411, kills: 112899, roundsSurvived: 7062, highestRound: 49, firstTrackedAt: evidence.startedAt, updatedAt: evidence.endedAt } };
}
function scope(search = '') {
(page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost/leaderboard${search}`) });
}
function html(data = report()) { return render(Leaderboard, { props: { data: { leaderboard: data, user: null, setupRequired: false } } }).body; }
beforeEach(() => scope());
it('shows five columns, preserves recorded ranks, and collapses totals, identity and evidence', () => {
const body = html();
const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>'));
expect(headers.match(/scope="col"/g)).toHaveLength(5);
expect(headers).toContain('Highest round'); expect(headers).toContain('Total kills'); expect(headers).toContain('Games played');
expect(headers).not.toContain('Headshots');
expect(body).toMatch(/<span[^>]*>27<\/span>/);
expect(body).toContain('26–26 of 60 players');
expect(body).toContain('473 players · 411 completed games · Highest round 49');
expect(body).not.toMatch(/<details[^>]*open/);
expect(body).not.toContain('Copy GUID'); expect(body).not.toContain('Every round counts');
expect(body).toContain('Record details'); expect(body).toContain('First observed at round 8');
expect(body).not.toContain('>Search</button>');
});
it('deduplicates the selected metric and preserves scope and pagination on player and match links', () => {
scope('?game=t5&map=t5%3Atheater&metric=kills&q=Cherry&offset=25');
const data = report(); data.metric = 'kills';
const body = html(data);
const headers = body.slice(body.indexOf('<thead'), body.indexOf('</thead>'));
expect(headers.match(/scope="col"/g)).toHaveLength(4);
expect(body).toContain('offset%3D25'); expect(body).toContain('q%3DCherry');
scope('?game=t5&map=t5%3Atheater&metric=highestRound&q=Cherry&offset=25');
const recordBody = html();
expect(recordBody).toContain('/matches/71?returnTo=%2Fleaderboard');
expect(recordBody).toContain('map%3Dt5%253Atheater');
expect(recordBody).toContain('Scope totals'); expect(recordBody).not.toContain('Fleet totals');
});
it('keeps qualification and denominator visible for efficiency rankings and missing values distinct', () => {
scope('?metric=headshotRate');
const data = report(); data.metric = 'headshotRate';
let body = html(data);
expect(body).toContain('At least 5 ended games and 100 kills within the selected game and map');
expect(body).toContain('100 headshots / 1,000 kills · 6 games');
expect(body).toContain('10%');
data.entries[0].headshotRate = null;
body = html(data); expect(body).toContain('>—');
});
it('holds changing ranks until explicitly applied, coalescing later polls even after focus leaves', () => {
const first = report();
const updates = createLeaderboardUpdates('all', first);
const second = structuredClone(first); second.entries[0].rank = 28;
expect(updates.receive('all', second, true)).toEqual({ current: first, pending: second });
const third = structuredClone(second); third.entries[0].rank = 30;
expect(updates.receive('all', third, false)).toEqual({ current: first, pending: third });
expect(updates.apply()).toEqual(third);
expect(updates.receive('all', third, true)?.pending).toBeNull();
});
it('ignores old scopes, clears a reverted pending update, and accepts unattended refreshes', () => {
const first = report(); const second = structuredClone(first); second.total = 100;
const updates = createLeaderboardUpdates('all', first);
updates.receive('all', second, true);
expect(updates.receive('all', structuredClone(first), true)?.pending).toBeNull();
expect(updates.receive('all', second, false)?.current).toEqual(second);
updates.receive('all', first, true);
updates.reset('t5', first);
expect(updates.receive('all', second, false)).toBeNull();
expect(updates.apply()).toEqual(first);
expect(leaderboardPageSize).toBe(25);
});