Add qualified efficiency leaderboard rankings

This commit is contained in:
2026-10-01 12:16:27 -04:00
parent c7d71c63ba
commit 47ebe83106
9 changed files with 385 additions and 25 deletions
+17 -7
View File
@@ -3,7 +3,8 @@
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 { leaderboardMetrics, playerProfileHref, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.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';
@@ -13,8 +14,8 @@
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> = {
const defaultColumns: PlayerStatMetric[] = ['highestRound', '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.',
@@ -26,6 +27,10 @@
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>(null);
let maps = $state<LeaderboardMap[]>([]);
@@ -187,7 +192,7 @@
<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><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">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
</div>
{#if data.entries.length}
@@ -201,7 +206,7 @@
<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>
<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>
@@ -213,16 +218,20 @@
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
<div class="identity"><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])}{#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'}<RecordSource evidence={entry.records[key]} />{/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)} {data.total === 1 ? 'player' : 'players'}{query ? ` matching “${query}”` : ''}</span>
<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} 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 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}
@@ -292,6 +301,7 @@
.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); }
@@ -1,7 +1,8 @@
<script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js';
import { invalidateAll } from '$app/navigation';
import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js';
import { playerStatMetrics, type PlayerProfile, type PlayerStatMetric } from '$lib/leaderboard.js';
import { efficiencyMetrics, efficiencyValue, efficiencyEligible, 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 CopyValue from '$ui/components/CopyValue.svelte';
@@ -14,8 +15,7 @@
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
const date = (value: string) => $timeFormat.dateTime(value);
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
const ratio = (value: number, total: number, percent = false) => total ? `${number.format(value / total * (percent ? 100 : 1))}${percent ? '%' : ''}` : '—';
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[];
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly PlayerStatMetric[];
let refreshing = $state(false);
let refreshError = $state('');
async function refresh() {
@@ -40,7 +40,7 @@
<section aria-labelledby="totals-title">
<h2 id="totals-title">Lifetime stats</h2>
<div class="stats">
{#each leaderboardMetrics as metric (metric.key)}
{#each playerStatMetrics as metric (metric.key)}
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong>{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={profile.records[metric.key]} />{/if}</div>
{/each}
</div>
@@ -49,11 +49,9 @@
<section aria-labelledby="metrics-title">
<h2 id="metrics-title">Player metrics</h2>
<div class="stats metrics">
<div class="stat"><span>Headshots / kills</span><strong>{ratio(stats.headshots, stats.kills, true)}</strong></div>
<div class="stat"><span>Kills per down</span><strong>{ratio(stats.kills, stats.downs)}</strong></div>
<div class="stat"><span>Average kills per game</span><strong>{ratio(stats.kills, stats.gamesPlayed)}</strong></div>
<div class="stat"><span>Average rounds per game</span><strong>{ratio(stats.roundsSurvived, stats.gamesPlayed)}</strong></div>
<div class="stat"><span>Completion rate</span><strong>{ratio(stats.gamesCompleted, stats.gamesPlayed, true)}</strong></div>
{#each efficiencyMetrics as metric (metric.key)}
<div class="stat"><span>{metric.label}</span><strong>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</strong><small>{efficiencySample(stats, metric.key)}</small><small>{efficiencyEligible(stats, metric.key) ? 'Qualifies for lifetime ranking' : `${efficiencyRequirement(metric.key)} to rank`}</small></div>
{/each}
</div>
<p class="note">Only completed games contribute. Completion rate measures how often the player appeared in the final report. A dash means there is no denominator for that metric.</p>
</section>
@@ -63,10 +61,10 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll" role="region" aria-label="Per-map player records" tabindex="0">
<table>
<thead><tr><th scope="col">Map</th>{#each leaderboardMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
<thead><tr><th scope="col">Map</th>{#each playerStatMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
{#each leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
{#each playerStatMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
{/each}</tbody>
</table>
</div>
@@ -77,7 +75,7 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll" role="region" aria-label="Player match history" tabindex="0">
<table>
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{leaderboardMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{playerStatMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
<tbody>{#each profile.matches as match (match.id)}
<tr><th scope="row">{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>