Show live match links and elapsed time (Refs #115)
CI / checks (push) Successful in 5m0s

This commit is contained in:
2026-10-09 23:47:46 -04:00
parent 3e0b4db736
commit a0663e43b8
2 changed files with 69 additions and 13 deletions
+31 -13
View File
@@ -2,9 +2,12 @@
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js';
import { goto } from '$app/navigation';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js';
import { uiNow } from '$ui/stores/servers.js';
import type { MatchHistoryResponse } from '$lib/match-history.js';
import {
matchApiParams,
@@ -16,7 +19,7 @@
} from '$lib/match-history-query.js';
import { matchInstanceLabel } from '$ui/match-history.js';
import { pollVisible } from '$ui/poll.js';
import { formatDuration } from '$ui/duration.js';
import { elapsedSince, formatDuration } from '$ui/duration.js';
import Button from './primitives/Button.svelte';
import EmptyState from './primitives/EmptyState.svelte';
import Dialog from './primitives/Dialog.svelte';
@@ -33,6 +36,7 @@
let tableRegion = $state<HTMLDivElement>();
let pendingUrl: URL | undefined;
let navigationId = 0;
const now = $derived(browser ? $uiNow : Date.now());
const filters = $derived(readMatchQuery(matchApiParams($page.url)));
const apiUrl = $derived.by(() => {
const params = matchApiParams($page.url);
@@ -308,7 +312,7 @@
? 'Unique players observed throughout the match'
: item.key === 'round'
? 'Final round, or highest observed round for unfinished matches'
: 'Recorded duration; unknown durations sort last'}
: 'Recorded duration; in-progress and unknown durations sort last'}
aria-describedby={item.key === 'players'
? 'match-player-help'
: undefined}
@@ -324,6 +328,12 @@
>
<tbody
>{#each data.matches as match (match.id)}
{@const duration =
match.status === 'active'
? elapsedSince(timestampISO(match.startedAt) ?? null, now)
: match.durationMs === null
? null
: formatDuration(match.durationMs)}
<tr>
<td><RelativeTime value={match.startedAt} /></td>
<td class="match">
@@ -333,6 +343,15 @@
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a
>
{:else if match.status === 'active' && !match.server_deleted_at}<a
class="match-link"
href={contextHref(
`/servers/${encodeURIComponent(match.server)}`,
$page.url
)}
aria-label={`View live ${match.map} match on ${matchInstanceLabel(match)}`}
>{match.map}</a
>
{:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context"
><RelativeTime value={match.startedAt} /> · {match.status ===
@@ -340,8 +359,8 @@
? 'In progress'
: match.status === 'completed'
? 'Completed'
: 'Interrupted'}{match.durationMs !== null
? ` · ${formatDuration(match.durationMs)}`
: 'Interrupted'}{duration !== null
? ` · ${duration}`
: ''}</small
>
{#if server === undefined}<div class="instance">
@@ -366,12 +385,9 @@
<td class="numeric">{match.uniquePlayers}</td>
<td class="numeric"
><span
title={match.durationMs === null
? 'Duration was not recorded or the match has not completed.'
: undefined}
>{match.durationMs === null
? '—'
: formatDuration(match.durationMs)}</span
title={duration === null
? 'Duration was not recorded.'
: undefined}>{duration ?? '—'}</span
></td
>
<td class="numeric"
@@ -423,9 +439,11 @@
<summary>About match history</summary>
<p>
Players counts unique players across all reports. A dash means the value is unavailable,
not zero. Duration requires a recorded start and completed ending. Round shows the final
round for completed matches and the highest observed round for unfinished matches. Open
a completed match for exact timestamps and starting and ending player counts.
not zero. In-progress matches show elapsed time since their recorded start and link to
the live server. Completed durations require a recorded start and completed ending.
Round shows the final round for completed matches and the highest observed round for
unfinished matches. Open a completed match for exact timestamps and starting and ending
player counts.
</p>
</details>
</section>
+38
View File
@@ -103,6 +103,43 @@ it('keeps mobile filters and sorting in one labelled sheet with distinct control
expect(new Set(ids).size).toBe(ids.length);
expect(body).toContain('Updates every 10 seconds');
});
it('links in-progress matches to their live server with the current filters and page', () => {
scope('?matchGame=t6&matchPage=2&matchStatus=active');
const data = report();
data.matches = [
{ ...match, server: 'Weekend lobby #1', status: 'active', endedAt: null, durationMs: null }
];
const body = render(MatchHistory, { props: { initial: data } }).body;
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
expect(rows.match(/<a\b/g)).toHaveLength(1);
expect(rows).toContain(
'href="/servers/Weekend%20lobby%20%231?returnTo=%2Fmatches%3FmatchGame%3Dt6%26matchPage%3D2%26matchStatus%3Dactive"'
);
expect(rows).toContain('aria-label="View live Town Survival match on Weekend lobby #1');
expect(rows).not.toContain('href="/matches/1');
data.matches[0].server_deleted_at = '2026-10-06T19:59:00Z';
const deleted = render(MatchHistory, { props: { initial: data } }).body;
expect(deleted).not.toContain('href="/servers/');
expect(deleted).toContain('Historical instance');
});
it.each([
['2026-10-06T19:59:48Z', '12s'],
['2026-10-06T19:36:28Z', '23m 32s'],
['2026-10-06T18:20:00Z', '1h 40m'],
['2026-10-06 19:36:28', '23m 32s'],
['2026-10-06T20:00:05Z', '0s']
])('shows running time from %s on desktop and mobile', (startedAt, duration) => {
const data = report();
data.matches = [{ ...match, status: 'active', startedAt, endedAt: null, durationMs: null }];
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
/<!--.*?-->/g,
''
);
expect(body).toContain(`In progress · ${duration}</small>`);
expect(body).toContain(`<span>${duration}</span>`);
expect(body).not.toContain('Duration was not recorded.');
});
it('labels unfinished rounds and missing durations without inventing final results or detail links', () => {
const data = report();
data.matches = [
@@ -112,6 +149,7 @@ it('labels unfinished rounds and missing durations without inventing final resul
expect(body).toContain('Interrupted');
expect(body).toContain('Highest observed');
expect(body).not.toContain('href="/matches/1');
expect(body).not.toContain('href="/servers/');
expect(body).toContain('>—</span>');
expect(body).toContain('>0</td>');
});