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 RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { timeFormat, timeZone } from '$ui/stores/time-zone.js'; 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 type { MatchHistoryResponse } from '$lib/match-history.js';
import { import {
matchApiParams, matchApiParams,
@@ -16,7 +19,7 @@
} from '$lib/match-history-query.js'; } from '$lib/match-history-query.js';
import { matchInstanceLabel } from '$ui/match-history.js'; import { matchInstanceLabel } from '$ui/match-history.js';
import { pollVisible } from '$ui/poll.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 Button from './primitives/Button.svelte';
import EmptyState from './primitives/EmptyState.svelte'; import EmptyState from './primitives/EmptyState.svelte';
import Dialog from './primitives/Dialog.svelte'; import Dialog from './primitives/Dialog.svelte';
@@ -33,6 +36,7 @@
let tableRegion = $state<HTMLDivElement>(); let tableRegion = $state<HTMLDivElement>();
let pendingUrl: URL | undefined; let pendingUrl: URL | undefined;
let navigationId = 0; let navigationId = 0;
const now = $derived(browser ? $uiNow : Date.now());
const filters = $derived(readMatchQuery(matchApiParams($page.url))); const filters = $derived(readMatchQuery(matchApiParams($page.url)));
const apiUrl = $derived.by(() => { const apiUrl = $derived.by(() => {
const params = matchApiParams($page.url); const params = matchApiParams($page.url);
@@ -308,7 +312,7 @@
? 'Unique players observed throughout the match' ? 'Unique players observed throughout the match'
: item.key === 'round' : item.key === 'round'
? 'Final round, or highest observed round for unfinished matches' ? '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' aria-describedby={item.key === 'players'
? 'match-player-help' ? 'match-player-help'
: undefined} : undefined}
@@ -324,6 +328,12 @@
> >
<tbody <tbody
>{#each data.matches as match (match.id)} >{#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> <tr>
<td><RelativeTime value={match.startedAt} /></td> <td><RelativeTime value={match.startedAt} /></td>
<td class="match"> <td class="match">
@@ -333,6 +343,15 @@
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a >{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} {:else}<span class="match-name">{match.map}</span>{/if}
<small class="mobile-match-context" <small class="mobile-match-context"
><RelativeTime value={match.startedAt} /> · {match.status === ><RelativeTime value={match.startedAt} /> · {match.status ===
@@ -340,8 +359,8 @@
? 'In progress' ? 'In progress'
: match.status === 'completed' : match.status === 'completed'
? 'Completed' ? 'Completed'
: 'Interrupted'}{match.durationMs !== null : 'Interrupted'}{duration !== null
? ` · ${formatDuration(match.durationMs)}` ? ` · ${duration}`
: ''}</small : ''}</small
> >
{#if server === undefined}<div class="instance"> {#if server === undefined}<div class="instance">
@@ -366,12 +385,9 @@
<td class="numeric">{match.uniquePlayers}</td> <td class="numeric">{match.uniquePlayers}</td>
<td class="numeric" <td class="numeric"
><span ><span
title={match.durationMs === null title={duration === null
? 'Duration was not recorded or the match has not completed.' ? 'Duration was not recorded.'
: undefined} : undefined}>{duration ?? '—'}</span
>{match.durationMs === null
? '—'
: formatDuration(match.durationMs)}</span
></td ></td
> >
<td class="numeric" <td class="numeric"
@@ -423,9 +439,11 @@
<summary>About match history</summary> <summary>About match history</summary>
<p> <p>
Players counts unique players across all reports. A dash means the value is unavailable, 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 not zero. In-progress matches show elapsed time since their recorded start and link to
round for completed matches and the highest observed round for unfinished matches. Open the live server. Completed durations require a recorded start and completed ending.
a completed match for exact timestamps and starting and ending player counts. 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> </p>
</details> </details>
</section> </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(new Set(ids).size).toBe(ids.length);
expect(body).toContain('Updates every 10 seconds'); 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', () => { it('labels unfinished rounds and missing durations without inventing final results or detail links', () => {
const data = report(); const data = report();
data.matches = [ data.matches = [
@@ -112,6 +149,7 @@ it('labels unfinished rounds and missing durations without inventing final resul
expect(body).toContain('Interrupted'); expect(body).toContain('Interrupted');
expect(body).toContain('Highest observed'); expect(body).toContain('Highest observed');
expect(body).not.toContain('href="/matches/1'); expect(body).not.toContain('href="/matches/1');
expect(body).not.toContain('href="/servers/');
expect(body).toContain('>—</span>'); expect(body).toContain('>—</span>');
expect(body).toContain('>0</td>'); expect(body).toContain('>0</td>');
}); });