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

393 lines
29 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">
let { data: initial }: { data: import('./$types').PageData } = $props();
import { timeFormat } from '$ui/stores/time-zone.js';
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 CopyValue from '$ui/components/CopyValue.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', 'headshots', 'revives', 'downs', 'gamesPlayed'];
const descriptions: Record<PlayerStatMetric, 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: 'Finished games with recorded player stats, including players who left before the end.',
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>(null);
let pendingUpdate = $state<LeaderboardResponse | null>(null);
let expandedPlayers = $state<string[]>([]);
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 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}`;
});
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 = null;
pendingUpdate = null; expandedPlayers = []; 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 = expandedPlayers.length > 0 || !!tableRegion?.contains(document.activeElement)
|| !!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;
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() {
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>) {
++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) 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" subtitle="All-time player records.">
{#snippet actions()}
<Button size="sm" loading={loading} disabled={changingFilters} 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>
<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>
</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}
<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" 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}
<!-- 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>
<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>
{#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>
{#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}
</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 || 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 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}
<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 completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game and map 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 <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>
.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-2); padding: 24px; 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: clamp(28px, 3vw, 40px); line-height: 1.15; 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) 16%, transparent), transparent); }
.record strong { color: var(--accent-primary-hover); font-size: clamp(36px, 4vw, 52px); }
.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; }
.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); }
.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); }
.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; }
.refresh-status { color: var(--text-secondary); font-size: var(--text-xs); white-space: nowrap; text-align: right; }
.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: 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(--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: 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; }
.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); }
.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); }
.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; }
@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; }
.filters > .field:nth-child(3) { grid-column: 1 / -1; }
.rankings-header { flex-direction: column; align-items: flex-start; padding: var(--space-4); }
.refresh-status { text-align: left; }
.player { min-width: 180px; max-width: 220px; }
th, td { padding: var(--space-3); }
.pagination { padding: var(--space-4); }
}
</style>