This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user