Files
electric-cherry/src/lib/ui/components/MatchHistory.svelte
T

108 lines
6.3 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">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { goto } from '$app/navigation';
import { timeFormat } from '$ui/stores/time-zone.js';
import type { ServerMatch } from '$lib/match-history.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
import Card from './primitives/Card.svelte';
import Button from './primitives/Button.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import ListFooter from './primitives/ListFooter.svelte';
let { server }: { server?: string } = $props();
let matches = $state<ServerMatch[]>([]);
let total = $state(0);
let limit = $state(25);
$effect(() => { limit = Math.min(10000, Math.max(25, Number($page.url.searchParams.get('matches')) || 25)); });
function saveContext() {
const url = new URL($page.url);
if (limit > 25) url.searchParams.set('matches', String(limit)); else url.searchParams.delete('matches');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
let loading = $state(true);
let error = $state('');
async function refresh(currentServer: string | undefined, currentLimit: number) {
try {
const endpoint = currentServer === undefined ? '/api/matches' : `/api/servers/${encodeURIComponent(currentServer)}/matches`;
const response = await fetch(`${endpoint}?limit=${currentLimit}`);
if (!response.ok) throw new Error('Could not load match history.');
const body = await response.json();
if (server !== currentServer || limit !== currentLimit) return;
matches = body.matches;
total = body.total;
error = '';
} catch (cause) {
if (server === currentServer && limit === currentLimit) error = (cause as Error).message;
} finally {
if (server === currentServer && limit === currentLimit) loading = false;
}
}
$effect(() => {
const currentServer = server;
const currentLimit = limit;
return pollVisible(() => refresh(currentServer, currentLimit), 10000);
});
</script>
<Card padding="lg">
<h2>Match history</h2>
<p>Newest matches first. Open a completed match to see its player results. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
{#if error}
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
{/if}
{#if loading}
<EmptyState title="Loading matches…" />
{:else if matches.length === 0 && !error}
<EmptyState title="No matches recorded yet" description={server === undefined ? 'Matches appear here when servers report gameplay.' : 'Matches appear here when this server reports gameplay.'} />
{:else if matches.length}
<!-- Keyboard focus lets users scroll the wide table horizontally. -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="table-scroll" tabindex="0" role="region" aria-label="Match history">
<table>
<thead><tr><th>Started</th><th>Ended</th>{#if server === undefined}<th>Server</th>{/if}<th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead>
<tbody>
{#each matches as match (match.id)}
<tr class:completed={match.status === 'completed'}>
<td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td>
<td>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</td>
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
<td>
{#if match.status === 'completed'}
<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>
{match.map}<small>View match →</small>
</a>
{:else}{match.map}{/if}
</td>
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td>
<td>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</td><td>{match.finalRound ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<ListFooter total={Math.min(total, 10000)} bind:limit onchange={saveContext} label="matches" />
{#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if}
{/if}
</Card>
<style>
h2 { margin: 0; font-size: var(--text-md); }
p { color: var(--text-secondary); font-size: var(--text-sm); }
.table-scroll { overflow-x: auto; }
.table-scroll:focus-visible { outline: none; box-shadow: var(--focus-ring); }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
th { color: var(--text-secondary); font-size: var(--text-xs); }
td { font-variant-numeric: tabular-nums; }
.completed:hover, .completed:focus-within { background: var(--surface-raised); }
.match-link { display: block; color: var(--accent-primary-hover); text-decoration: none; }
.match-link:hover { text-decoration: underline; }
.match-link:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
.match-link small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); }
</style>