Add qualified efficiency leaderboard rankings
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import type { PlayerStats } from './leaderboard.js';
|
||||
|
||||
export const EFFICIENCY_MIN_GAMES = 5;
|
||||
export const EFFICIENCY_MIN_KILLS = 100;
|
||||
export const EFFICIENCY_MIN_DOWNS = 5;
|
||||
|
||||
export const efficiencyMetrics = [
|
||||
{ key: 'headshotRate', label: 'Headshots / kills', format: 'percent',
|
||||
description: `Total headshots divided by total kills in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_KILLS} kills.` },
|
||||
{ key: 'killsPerDown', label: 'Kills per down', format: 'number',
|
||||
description: `Total kills divided by total downs in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_DOWNS} downs.` },
|
||||
{ key: 'averageKills', label: 'Average kills', format: 'number',
|
||||
description: `Total kills divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.` },
|
||||
{ key: 'averageRounds', label: 'Average rounds', format: 'number',
|
||||
description: `Total observed rounds divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.` },
|
||||
{ key: 'completionRate', label: 'Completion rate', format: 'percent',
|
||||
description: `Games with the player in the final report divided by games played. Only ended games count, including games the player left early. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games.` }
|
||||
] as const;
|
||||
|
||||
export type EfficiencyMetric = typeof efficiencyMetrics[number]['key'];
|
||||
const metrics = new Set<string>(efficiencyMetrics.map(metric => metric.key));
|
||||
|
||||
export function isEfficiencyMetric(metric: string): metric is EfficiencyMetric {
|
||||
return metrics.has(metric);
|
||||
}
|
||||
|
||||
function sample(stats: PlayerStats, metric: EfficiencyMetric): [number, number] {
|
||||
switch (metric) {
|
||||
case 'headshotRate': return [stats.headshots, stats.kills];
|
||||
case 'killsPerDown': return [stats.kills, stats.downs];
|
||||
case 'averageKills': return [stats.kills, stats.gamesPlayed];
|
||||
case 'averageRounds': return [stats.roundsSurvived, stats.gamesPlayed];
|
||||
case 'completionRate': return [stats.gamesCompleted, stats.gamesPlayed];
|
||||
}
|
||||
}
|
||||
|
||||
/** Calculate from aggregate counters; qualification never changes a recorded value. */
|
||||
export function efficiencyValue(stats: PlayerStats, metric: EfficiencyMetric): number | null {
|
||||
const [numerator, denominator] = sample(stats, metric);
|
||||
return denominator === 0 ? null : numerator / denominator;
|
||||
}
|
||||
|
||||
export function efficiencyEligible(stats: PlayerStats, metric: EfficiencyMetric): boolean {
|
||||
if (stats.gamesPlayed < EFFICIENCY_MIN_GAMES) return false;
|
||||
if (metric === 'headshotRate') return stats.kills >= EFFICIENCY_MIN_KILLS;
|
||||
if (metric === 'killsPerDown') return stats.downs >= EFFICIENCY_MIN_DOWNS;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function efficiencyRequirement(metric: EfficiencyMetric): string {
|
||||
const games = `At least ${EFFICIENCY_MIN_GAMES} ended games`;
|
||||
if (metric === 'headshotRate') return `${games} and ${EFFICIENCY_MIN_KILLS} kills`;
|
||||
if (metric === 'killsPerDown') return `${games} and ${EFFICIENCY_MIN_DOWNS} downs`;
|
||||
return games;
|
||||
}
|
||||
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const count = (value: number, singular: string, plural = `${singular}s`) =>
|
||||
`${number.format(value)} ${value === 1 ? singular : plural}`;
|
||||
|
||||
export function efficiencySample(stats: PlayerStats, metric: EfficiencyMetric): string {
|
||||
switch (metric) {
|
||||
case 'headshotRate': return `${count(stats.headshots, 'headshot')} / ${count(stats.kills, 'kill')}`;
|
||||
case 'killsPerDown': return `${count(stats.kills, 'kill')} / ${count(stats.downs, 'down')}`;
|
||||
case 'averageKills': return `${count(stats.kills, 'kill')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||
case 'averageRounds': return `${count(stats.roundsSurvived, 'observed round')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||
case 'completionRate': return `${count(stats.gamesCompleted, 'finish', 'finishes')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const decimal = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||
export function formatEfficiencyValue(value: number | null, metric: EfficiencyMetric): string {
|
||||
if (value === null) return '—';
|
||||
const percent = metric === 'headshotRate' || metric === 'completionRate';
|
||||
return `${decimal.format(value * (percent ? 100 : 1))}${percent ? '%' : ''}`;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export const leaderboardMetrics = [
|
||||
import { efficiencyMetrics, type EfficiencyMetric } from './leaderboard-efficiency.js';
|
||||
|
||||
export const playerStatMetrics = [
|
||||
{ key: 'highestRound', label: 'Highest round' },
|
||||
{ key: 'kills', label: 'Total kills' },
|
||||
{ key: 'roundsSurvived', label: 'Total rounds' },
|
||||
@@ -11,9 +13,11 @@ export const leaderboardMetrics = [
|
||||
{ key: 'bestScore', label: 'Highest reported score' }
|
||||
] as const;
|
||||
|
||||
export const leaderboardMetrics = [...playerStatMetrics, ...efficiencyMetrics] as const;
|
||||
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
||||
export type PlayerStatMetric = typeof playerStatMetrics[number]['key'];
|
||||
|
||||
export type PlayerStats = Record<LeaderboardMetric, number>;
|
||||
export type PlayerStats = Record<PlayerStatMetric, number>;
|
||||
export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore';
|
||||
export interface RecordEvidence {
|
||||
matchId: number;
|
||||
@@ -71,7 +75,7 @@ export function playerProfileHref(guid: string): string {
|
||||
return `/leaderboard/players/${encodeURIComponent(guid)}`;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
export interface LeaderboardEntry extends Record<EfficiencyMetric, number | null> {
|
||||
rank: number;
|
||||
guid: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||
import { EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS, EFFICIENCY_MIN_DOWNS,
|
||||
isEfficiencyMetric } from '../../leaderboard-efficiency.js';
|
||||
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
|
||||
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
|
||||
|
||||
@@ -25,6 +27,11 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
|
||||
const scoped = `FROM leaderboard_match_players mp
|
||||
JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||
WHERE ${conditions.join(' AND ')}`;
|
||||
const qualification = isEfficiencyMetric(metric)
|
||||
? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL`
|
||||
+ (metric === 'headshotRate' ? ` AND measures.kills >= ${EFFICIENCY_MIN_KILLS}` : '')
|
||||
+ (metric === 'killsPerDown' ? ` AND measures.downs >= ${EFFICIENCY_MIN_DOWNS}` : '')
|
||||
: '';
|
||||
const aggregated = `WITH totals AS (
|
||||
SELECT mp.guid, MAX(mp.highest_round) AS highestRound,
|
||||
SUM(mp.kills) AS kills, SUM(mp.rounds_survived) AS roundsSurvived,
|
||||
@@ -32,12 +39,18 @@ export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardRes
|
||||
COUNT(*) AS gamesPlayed, SUM(mp.completed) AS gamesCompleted,
|
||||
MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore, MAX(mp.last_seen_at) AS lastSeen
|
||||
${scoped} GROUP BY mp.guid
|
||||
), measures AS (
|
||||
SELECT totals.*, 1.0 * headshots / NULLIF(kills, 0) AS headshotRate,
|
||||
1.0 * kills / NULLIF(downs, 0) AS killsPerDown,
|
||||
1.0 * kills / NULLIF(gamesPlayed, 0) AS averageKills,
|
||||
1.0 * roundsSurvived / NULLIF(gamesPlayed, 0) AS averageRounds,
|
||||
1.0 * gamesCompleted / NULLIF(gamesPlayed, 0) AS completionRate FROM totals
|
||||
), identities AS (
|
||||
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS recency
|
||||
FROM leaderboard_players WHERE game IN ('t5', 't6')
|
||||
), ranked AS (
|
||||
SELECT totals.*, p.name, RANK() OVER (ORDER BY totals.${metric} DESC) AS rank
|
||||
FROM totals JOIN identities p ON p.guid = totals.guid AND p.recency = 1
|
||||
SELECT measures.*, p.name, RANK() OVER (ORDER BY measures.${metric} DESC) AS rank
|
||||
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
|
||||
)`;
|
||||
const query = options.q?.trim() ?? '';
|
||||
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user