Add qualified efficiency leaderboard rankings
This commit is contained in:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user