Add completed match detail pages

This commit is contained in:
2026-10-01 11:45:23 -04:00
parent aa0223c35d
commit a3cd3ee585
9 changed files with 409 additions and 13 deletions
@@ -78,7 +78,7 @@
<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>
<tbody>{#each profile.matches as match (match.id)}
<tr><th scope="row">{match.mapName}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
<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>
{#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each}
</tr>
+9
View File
@@ -0,0 +1,9 @@
import { error } from '@sveltejs/kit';
import { getMatchDetail } from '$lib/server/fleet/match-history.js';
export function load({ params }: { params: { id: string } }) {
if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found');
const match = getMatchDetail(Number(params.id));
if (!match) error(404, 'Match not found');
return { match };
}
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts">
import type { MatchDetail } from '$lib/match-history.js';
import { playerProfileHref } from '$lib/leaderboard.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import { formatDuration } from '$ui/duration.js';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Badge from '$ui/components/primitives/Badge.svelte';
import Card from '$ui/components/primitives/Card.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import Stat from '$ui/components/primitives/Stat.svelte';
let { data }: { data: { match: MatchDetail } } = $props();
const match = $derived(data.match);
const gameName = $derived(match.game === 't5' ? 'Black Ops I' : match.game === 't6' ? 'Black Ops II' : match.game.toUpperCase());
const number = new Intl.NumberFormat('en-US');
const stats = [
{ key: 'highestRound', label: 'Highest round' },
{ key: 'kills', label: 'Kills' },
{ key: 'headshots', label: 'Headshots' },
{ key: 'revives', label: 'Revives' },
{ key: 'downs', label: 'Downs' },
{ key: 'roundsSurvived', label: 'Rounds survived' },
{ key: 'bestScore', label: 'Highest reported score' }
] as const;
</script>
<svelte:head><title>{match.map} · Match #{match.id} · Electric Cherry</title></svelte:head>
<div class="match-page">
<SectionHeader title={match.map} eyebrow={`Match #${match.id}`} subtitle={`${gameName} · Recorded match results`}>
{#snippet crumbs()}<a href="/matches">← Match History</a>{/snippet}
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
</SectionHeader>
<section class="summary" aria-label="Match summary">
<Card><Stat label="Final round" value={match.finalRound} tone="accent" size="lg" tween={false} /></Card>
<Card><Stat label="Game time" value={match.durationMs === null ? null : formatDuration(match.durationMs)} size="lg" tween={false} /></Card>
<Card><Stat label="Unique players" value={match.uniquePlayers} size="lg" tween={false} /></Card>
<Card><Stat label="Players at end" value={match.endPlayers} size="lg" tween={false} /></Card>
</section>
<Card padding="lg">
<h2>Match details</h2>
<dl class="details">
<div class="server"><dt>Server</dt><dd><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></dd></div>
<div><dt>{match.startObserved ? 'Started' : 'First observed'}</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
<div><dt>Ended</dt><dd>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</dd></div>
<div><dt>Players at start</dt><dd>{match.startPlayers ?? '—'}</dd></div>
</dl>
{#if !match.startObserved}<p class="note">The match start was not recorded. The first observation is shown above; the full game time is unknown.</p>{/if}
</Card>
<section class="results" aria-labelledby="players-title">
<div class="results-heading">
<h2 id="players-title">Player results</h2>
<p>Every recorded player, including those absent from the final report. Stats are the highest reported counters for this match.</p>
</div>
{#if match.players.length === 0}
<EmptyState title="No player results recorded" description="This match has no saved player reports." />
{:else}
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
<div class="table-scroll" role="region" aria-label="Match player results" tabindex="0">
<table>
<thead><tr><th scope="col">Player</th><th scope="col">Participation</th>{#each stats as stat}<th scope="col">{stat.label}</th>{/each}<th scope="col">Last seen</th></tr></thead>
<tbody>
{#each match.players as player (player.guid)}
<tr>
<th scope="row">
{#if player.canViewProfile}<a href={playerProfileHref(player.guid)}>{player.name}</a>{:else}{player.name}{/if}
<small class="guid">{player.guid}</small>
</th>
<td class="participation"><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}>{player.presentAtEnd ? 'At match end' : 'Not at match end'}</Badge>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td>
{#each stats as stat}<td class:round={stat.key === 'highestRound'}>{player[stat.key] === null ? '—' : number.format(player[stat.key]!)}</td>{/each}
<td><time datetime={player.lastSeenAt}>{$timeFormat.dateTime(player.lastSeenAt)}</time></td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<details class="note">
<summary>About these results</summary>
<p>Kills, headshots, revives, downs, and score retain each player's highest reported value. Highest reported score is a points balance, not total points earned or necessarily the final score.</p>
<p>Highest round includes late joins. Rounds survived counts only observed round progression while the player was present. “At match end” means the player appeared in the final report; other players keep their earlier stats. A dash means no value was recorded.</p>
<p>Player names use the latest recorded identity. Times reflect when reports were received and use your selected timezone. These results remain available after the server is deleted.</p>
</details>
</div>
<style>
.match-page { display: grid; gap: var(--space-6); min-width: 0; }
a { color: var(--accent-primary-hover); }
a:focus-visible, .table-scroll:focus-visible, summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
h2 { margin: 0; font-size: var(--text-md); }
.summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
.details { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5); margin: var(--space-5) 0 0; }
.server { grid-column: 1 / -1; }
dt { margin-bottom: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); }
dd { margin: 0; font-size: var(--text-sm); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.results { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.results-heading { padding: var(--space-6); }
.results-heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: var(--space-4); text-align: right; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
thead th { background: var(--surface-sunken); color: var(--text-secondary); font-size: var(--text-xs); }
th:first-child { text-align: left; min-width: 180px; max-width: 280px; white-space: normal; overflow-wrap: anywhere; }
th:nth-child(2), .participation { text-align: left; }
tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; }
td { font-variant-numeric: tabular-nums; }
.round { color: var(--accent-primary-hover); }
small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); color: var(--text-tertiary); font-weight: normal; }
.guid { font-family: var(--font-mono); }
.note { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
p.note { margin: var(--space-4) 0 0; }
summary { cursor: pointer; color: var(--text-secondary); }
@media (max-width: 800px) { .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 480px) { .summary { gap: var(--space-3); } .details { grid-template-columns: minmax(0, 1fr); } .results-heading { padding: var(--space-4); } }
</style>