Files
electric-cherry/src/routes/leaderboard/+page.svelte
T
2026-10-12 00:58:05 -04:00

1076 lines
39 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 Field from '$ui/components/primitives/Field.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
let { data: initial }: { data: import('./$types').PageData } = $props();
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page, navigating } from '$app/stores';
const changingFilters = $derived($navigating?.to?.route.id === '/leaderboard');
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 {
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';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import Freshness from '$ui/components/Freshness.svelte';
import RecordSource from '$ui/components/RecordSource.svelte';
const number = new Intl.NumberFormat('en-US');
const pageSize = leaderboardPageSize;
const games = [
{ key: 't5', label: 'Black Ops I' },
{ key: 't6', label: 'Black Ops II' }
];
const defaultColumns: PlayerStatMetric[] = ['kills', 'roundsSurvived', 'gamesPlayed'];
const squadSizes = [
{ key: '1', label: 'Solo' },
{ key: '2', label: 'Duo' },
{ key: '3', label: 'Trio' },
{ key: '4', label: 'Four players' }
];
const descriptions: Record<PlayerStatMetric, string> = {
highestRound: 'The highest recorded round, including players who joined mid-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:
'Finished games with recorded player stats, excluding players who left in the same round they joined.',
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.'
};
const description = (key: LeaderboardMetric) =>
isEfficiencyMetric(key)
? efficiencyMetrics.find((item) => item.key === key)!.description
: descriptions[key];
const formatValue = (entry: LeaderboardEntry, key: LeaderboardMetric) =>
isEfficiencyMetric(key)
? formatEfficiencyValue(entry[key], key)
: number.format(entry[key]);
let data = $state<LeaderboardResponse | null>(untrack(() => initial.leaderboard));
let maps = $state<LeaderboardMap[]>(untrack(() => initial.leaderboard.maps));
let loading = $state(false);
let error = $state('');
let search = $state(
untrack(() => ($page.url.searchParams.get('q') ?? '').trim().slice(0, 100))
);
let checkedAt = $state<Date | null>(untrack(() => (initial.leaderboard ? new Date() : null)));
let pendingUpdate = $state<LeaderboardResponse | null>(null);
let filtersOpen = $state(false);
let showAllStats = $state(false);
let tableRegion = $state<HTMLDivElement>();
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 players = $derived(
squadSizes.some((size) => size.key === $page.url.searchParams.get('players'))
? $page.url.searchParams.get('players')!
: ''
);
const playersLabel = $derived(
squadSizes.find((size) => size.key === players)?.label ?? 'All squad sizes'
);
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,
...(showAllStats ? playerStatMetrics.map((item) => item.key) : defaultColumns).filter(
(key) => key !== metric
)
]);
const filterCount = $derived([game, map, players, query].filter(Boolean).length);
const apiUrl = $derived.by(() => {
const params = new URLSearchParams({
metric,
limit: String(pageSize),
offset: String(offset)
});
if (game) params.set('game', game);
params.set('map', map);
if (players) params.set('players', players);
if (query) params.set('q', query);
return `/api/leaderboard?${params}`;
});
const updates = untrack(() => createLeaderboardUpdates(apiUrl, initial.leaderboard));
$effect(() => {
search = query;
});
$effect(() => {
const report = initial.leaderboard;
untrack(() => {
++requestId;
controller?.abort();
data = report;
maps = report.maps;
loadedUrl = apiUrl;
loading = false;
error = '';
checkedAt = new Date();
pendingUpdate = null;
updates.reset(apiUrl, report);
});
});
onMount(() => {
const refreshVisible = () => {
if (!document.hidden && !loading && !changingFilters && !pendingUrl)
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) 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 || url !== apiUrl || changingFilters || pendingUrl) return;
const inspecting = !!tableRegion?.contains(document.activeElement);
const next = updates.receive(url, result, inspecting);
if (!next) return;
data = next.current;
maps = next.current.maps;
pendingUpdate = next.pending;
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 applyUpdate() {
data = updates.apply();
maps = data.maps;
pendingUpdate = null;
}
function updateFilters(changes: Record<string, string | null>) {
++requestId;
controller?.abort();
loading = false;
// 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 || (key === 'map' && value !== null)) 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: null, players: '', 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" subtitle="Records by map and starting squad.">
{#snippet actions()}<div class="mobile-filter-trigger">
<Button size="sm" onclick={() => (filtersOpen = true)}
>Filters{filterCount ? ` · ${filterCount}` : ''}</Button
>
</div>{/snippet}
</SectionHeader>
{#snippet filterFields(suffix: string)}
<div class="filters">
<Field label="Game" forId={`leaderboard-game${suffix}`}>
<Select
id={`leaderboard-game${suffix}`}
value={game}
onchange={(event) =>
updateFilters({ game: event.currentTarget.value, map: null })}
>
<option value="">All games</option>{#each games as item (item.key)}<option
value={item.key}>{item.label}</option
>{/each}
</Select>
</Field>
<Field label="Map" forId={`leaderboard-map${suffix}`}>
<Select
id={`leaderboard-map${suffix}`}
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 === 't5' ? 'BO1' : 'BO2'}`
: ''}</option
>{/each}
</Select>
</Field>
<Field label="Players" forId={`leaderboard-players${suffix}`}>
<Select
id={`leaderboard-players${suffix}`}
value={players}
onchange={(event) => updateFilters({ players: event.currentTarget.value })}
aria-describedby={`players-help${suffix}`}
>
<option value="">All squad sizes</option>{#each squadSizes as size}<option
value={size.key}>{size.label}</option
>{/each}
</Select><span id={`players-help${suffix}`} class="sr-only"
>Players recorded at the start of each match. Matches with unknown starting
counts appear only under All squad sizes.</span
>
</Field>
<Field label="Rank by" forId={`leaderboard-metric${suffix}`}>
<Select
id={`leaderboard-metric${suffix}`}
value={metric}
onchange={(event) => updateFilters({ metric: event.currentTarget.value })}
>
{#each leaderboardMetrics as item (item.key)}<option value={item.key}
>{item.label}</option
>{/each}
</Select>
</Field>
<form
class="search"
onsubmit={(event) => {
event.preventDefault();
updateFilters({ q: search.trim() });
if (suffix) filtersOpen = false;
}}
role="search"
>
<Field label="Find a player" forId={`leaderboard-search${suffix}`}>
<Input
id={`leaderboard-search${suffix}`}
type="search"
bind:value={search}
maxlength={100}
placeholder="Name or GUID"
aria-describedby={`search-help${suffix}`}
/></Field
><span id={`search-help${suffix}`} class="sr-only"
>Press Enter to search. Player ranks are preserved.</span
>
</form>
</div>
{/snippet}
<div class="desktop-filters">{@render filterFields('')}</div>
<Dialog
bind:open={filtersOpen}
title="Leaderboard filters"
description="Choose a map, starting squad size, and ranking."
drawer
>
{@render filterFields('-mobile')}
{#snippet actions()}<Button variant="ghost" onclick={clearFilters}>Reset filters</Button
><Button variant="primary" onclick={() => (filtersOpen = false)}>Show results</Button
>{/snippet}
</Dialog>
<div class="scope-row">
<div>
<span class="scope">{scope}</span><span class="all-time">{playersLabel}</span><span
class="all-time">All time</span
>
</div>
{#if game || players || query}<button class="text-button" onclick={clearFilters}
>Reset 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}
<section class="overview" aria-label={`All-time stats for ${scope}`}>
<div class="record">
<span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong
><small>{game || map ? 'Selection record' : '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>Player rounds</small>
</div>
<div>
<span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong
><small>Completed games</small>
</div>
<div>
<span>Players</span><strong>{number.format(data.summary.players)}</strong><small
>Unique players</small
>
</div>
</section>
<section
class="rankings"
class:show-all-stats={showAllStats}
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}
<p class="metric-description">
{description(metric)} Equal values share a rank.
</p>
</div>
<div class="ranking-controls">
<label class="all-stats"
><input type="checkbox" bind:checked={showAllStats} /> Show all stats</label
>{#if !error}<Freshness
updatedAt={checkedAt?.getTime() ?? null}
intervalLabel="Updates every 15 seconds"
/>{/if}
</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}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Keyboard users need to scroll the table.) -->
<div
class="table-scroll"
bind:this={tableRegion}
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={description(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.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={playerContextHref(
entry.guid,
`${$page.url.pathname}${$page.url.search}`
)}>{entry.name}</a
>
</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 === metric && (key === 'highestRound' || key === 'bestKills' || key === 'bestScore')}
<RecordSource
evidence={entry.records[key]}
metric={metricLabel}
returnTo={`${$page.url.pathname}${$page.url.search}`}
/>
{/if}
</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)}
{isEfficiencyMetric(metric) ? 'qualifying ' : ''}{data.total === 1
? 'player'
: 'players'}{query ? ` matching “${query}”` : ''}</span
>
<div>
<Button
size="sm"
disabled={data.offset === 0 || loading || changingFilters}
onclick={() =>
updateFilters({ offset: String(Math.max(0, offset - pageSize)) })}
>Previous</Button
><Button
size="sm"
disabled={data.offset + data.entries.length >= data.total ||
loading ||
changingFilters}
onclick={() => updateFilters({ offset: String(offset + pageSize) })}
>Next</Button
>
</div>
</div>
{:else if isEfficiencyMetric(metric)}
<EmptyState
spacious
title="No qualifying players found"
description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}
>
{#snippet actions()}<Button
size="sm"
onclick={() => updateFilters({ metric: 'highestRound' })}
>Rank by highest round</Button
>{/snippet}
</EmptyState>
{:else if query || game || map || players || offset}
<EmptyState
spacious
title="No players found"
description="Try a different game, map, starting squad size, or player name."
>
{#snippet actions()}<Button size="sm" onclick={clearFilters}
>Reset filters</Button
>{/snippet}
</EmptyState>
{:else}
<EmptyState
spacious
title="Your first records start here"
description="Player stats appear after a game ends, once the player has been observed at round 2 or later."
/>
{/if}
</section>
{:else if loading}
<div class="loading" role="status">Loading player records…</div>
{/if}
{#if data}<details class="mobile-totals">
<summary>Selection totals</summary>
<dl>
<div>
<dt>Highest round</dt>
<dd>{number.format(data.summary.highestRound)}</dd>
</div>
<div>
<dt>Total kills</dt>
<dd>{number.format(data.summary.kills)}</dd>
</div>
<div>
<dt>Total rounds</dt>
<dd>{number.format(data.summary.roundsSurvived)}</dd>
</div>
<div>
<dt>Games tracked</dt>
<dd>{number.format(data.summary.matches)}</dd>
</div>
<div>
<dt>Players</dt>
<dd>{number.format(data.summary.players)}</dd>
</div>
</dl>
</details>{/if}
<details class="tracking-notes">
<summary>How these records work</summary>
<div>
<p>
Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name
changes keep a player's records together. The latest reported name across both games
appears here and on the player's profile, regardless of filters.
</p>
<p>
Only games with a received end report contribute to records. Games in progress,
interrupted games, and games missing their end report stay out of totals and
rankings. Players who left a completed game keep their recorded stats. Game, map,
and starting squad-size filters choose which matches contribute; player search
preserves ranks. Player totals count each GUID once. The top stats cover all
recorded players in the selected game and map; player search and ranking
qualification do not change those totals.
</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 <RelativeTime
value={data.summary.firstTrackedAt}
/> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <RelativeTime
value={data.summary.updatedAt}
/>.{/if}
</p>
</div>
</details>
</section>
<style>
.overview {
display: grid;
grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr));
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
background: var(--surface);
overflow: hidden;
}
.overview > div {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--space-1);
padding: var(--space-4);
border-right: 1px solid var(--border-subtle);
min-width: 0;
}
.overview > div:last-child {
border-right: 0;
}
.overview span,
.overview small {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.overview strong {
font-size: var(--text-xl);
line-height: 1.15;
font-weight: var(--weight-semibold);
font-variant-numeric: tabular-nums;
letter-spacing: -0.035em;
overflow-wrap: anywhere;
}
.overview .record {
background: var(--surface-raised);
}
.record strong {
color: var(--text-primary);
font-size: var(--text-2xl);
}
summary {
cursor: pointer;
}
.pending-update {
padding: 0 var(--space-5) var(--space-3);
}
.text-button:focus-visible,
summary:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 3px;
}
.page {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.filters {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
}
.search {
grid-column: 1 / -1;
}
.search {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
label {
color: var(--text-secondary);
font-size: var(--text-xs);
}
input,
button {
font: inherit;
}
input {
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);
}
.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);
}
.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;
}
.table-scroll {
position: relative;
max-width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: var(--text-sm);
}
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);
}
.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(--text-primary);
background: var(--surface-raised);
font-weight: var(--weight-semibold);
}
.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);
}
.player-name {
display: block;
color: var(--text-primary);
overflow-wrap: anywhere;
}
.stat {
text-align: right;
white-space: nowrap;
}
td.stat {
font-family: var(--font-numeric);
font-variant-numeric: tabular-nums;
}
.stat.selected {
color: var(--text-primary);
}
th.stat.selected {
color: var(--accent-primary-hover);
}
.sample {
display: block;
max-width: 22rem;
margin-top: var(--space-2);
white-space: normal;
font-family: var(--font-body);
font-size: var(--text-xs);
font-weight: normal;
color: var(--text-secondary);
}
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;
}
.ranking-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-2);
}
.all-stats {
display: flex;
align-items: center;
gap: var(--space-1);
white-space: nowrap;
font-size: var(--text-xs);
}
.all-stats input {
width: 16px;
height: 16px;
min-height: 0;
accent-color: var(--accent-primary);
}
.mobile-filter-trigger,
.mobile-totals {
display: none;
}
.mobile-totals {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.mobile-totals dl {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.mobile-totals dd {
margin: var(--space-1) 0 0;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1000px) {
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 800px) {
.overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview > div {
padding: 20px;
}
.overview .record {
grid-column: 1 / -1;
border-right: 0;
}
.overview > div:nth-child(-n + 3) {
border-bottom: 1px solid var(--border-subtle);
}
.overview > div:nth-child(3) {
border-right: 0;
}
}
@media (max-width: 640px) {
.page {
gap: var(--space-4);
}
.filters {
gap: var(--space-3);
}
.search {
grid-column: 1 / -1;
}
.desktop-filters,
.overview {
display: none;
}
.mobile-filter-trigger,
.mobile-totals {
display: block;
}
.scope-row {
font-size: var(--text-sm);
}
.scope-row > div {
gap: var(--space-1) var(--space-2);
}
.all-time {
padding-left: var(--space-2);
}
.filters {
grid-template-columns: minmax(0, 1fr);
}
.ranking-controls {
justify-content: flex-start;
}
.metric-description {
display: none;
}
.rankings:not(.show-all-stats) .stat:not(.selected) {
display: none;
}
.rank {
width: 36px;
}
.rank span {
min-width: 20px;
}
.player {
position: static;
min-width: 90px;
max-width: 140px;
}
.selected :global(.record-source) {
max-width: 150px;
}
.rankings-header {
flex-direction: column;
align-items: flex-start;
padding: var(--space-4);
}
th,
td {
padding: var(--space-3);
}
.pagination {
padding: var(--space-4);
}
}
</style>