Refine chat layout and collapse consecutive match events

This commit is contained in:
2026-10-02 13:44:44 -04:00
parent d7b3e124e6
commit a2e2311aa9
4 changed files with 172 additions and 41 deletions
+1
View File
@@ -44,6 +44,7 @@ export function createTimeFormatter(zone: string) {
dateTime: formatter({ ...date, ...time }), dateTime: formatter({ ...date, ...time }),
date: formatter(date), date: formatter(date),
time: formatter(time), time: formatter(time),
clockTime: formatter({ hour: 'numeric', minute: '2-digit', second: '2-digit' }),
minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }), minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }),
period: formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }) period: formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })
}; };
+22
View File
@@ -0,0 +1,22 @@
import type { ContextualChatEntry } from '../game-chat.js';
export type ChatMessageEntry = Extract<ContextualChatEntry, { type: 'message' }>;
export type ChatBoundaryEntry = Extract<ContextualChatEntry, { type: 'boundary' }>;
export type ChatTimelineItem =
| { type: 'message'; entry: ChatMessageEntry }
| { type: 'boundaries'; entries: ChatBoundaryEntry[] };
/** Group adjacent lifecycle events for display without discarding log history. */
export function groupChatTimeline(entries: readonly ContextualChatEntry[]): ChatTimelineItem[] {
const timeline: ChatTimelineItem[] = [];
for (const entry of entries) {
if (entry.type === 'message') {
timeline.push({ type: 'message', entry });
} else {
const previous = timeline.at(-1);
if (previous?.type === 'boundaries') previous.entries.push(entry);
else timeline.push({ type: 'boundaries', entries: [entry] });
}
}
return timeline;
}
+95 -41
View File
@@ -2,24 +2,31 @@
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js'; import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
import { formatDuration } from '$ui/duration.js'; import { formatDuration } from '$ui/duration.js';
import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
let { server }: { server: string } = $props(); let { server }: { server: string } = $props();
let entries = $state<ContextualChatEntry[]>([]); let entries = $state<ContextualChatEntry[]>([]);
let timeline = $derived(groupChatTimeline(entries));
let hasMessages = $derived(entries.some(entry => entry.type === 'message')); let hasMessages = $derived(entries.some(entry => entry.type === 'message'));
let loaded = $state(false); let loaded = $state(false);
let loading = $state(false); let loading = $state(false);
let manualRefreshing = $state(false);
let error = $state(''); let error = $state('');
let missing = $state(false); let missing = $state(false);
let body = $state<HTMLDivElement | null>(null); let body = $state<HTMLDivElement | null>(null);
let follow = $state(true); let follow = $state(true);
let controller: AbortController | null = null; let controller: AbortController | null = null;
async function refresh() { async function refresh(manual = false) {
if (loading) return; if (loading) {
if (manual) manualRefreshing = true;
return;
}
loading = true; loading = true;
manualRefreshing = manual;
const request = new AbortController(); const request = new AbortController();
controller = request; controller = request;
try { try {
@@ -35,7 +42,7 @@
if (follow && body) body.scrollTop = body.scrollHeight; if (follow && body) body.scrollTop = body.scrollHeight;
} catch (err) { } catch (err) {
if (!request.signal.aborted) error = err instanceof Error ? err.message : 'Could not refresh chat.'; if (!request.signal.aborted) error = err instanceof Error ? err.message : 'Could not refresh chat.';
} finally { loading = false; } } finally { loading = false; manualRefreshing = false; }
} }
function onScroll() { function onScroll() {
@@ -47,28 +54,43 @@
if (body) body.scrollTop = body.scrollHeight; if (body) body.scrollTop = body.scrollHeight;
} }
onMount(() => { onMount(() => {
const stop = pollVisible(refresh, 5000); const stop = pollVisible(() => refresh(), 5000);
return () => { stop(); controller?.abort(); }; return () => { stop(); controller?.abort(); };
}); });
</script> </script>
{#snippet unavailable()}
<span aria-hidden="true">—</span><span class="sr-only">Unavailable</span>
{/snippet}
{#snippet clockTime(value: string)}
<time datetime={value} title={$timeFormat.dateTime(value)} aria-label={$timeFormat.dateTime(value)}>{$timeFormat.clockTime(value)}</time>
{/snippet}
{#snippet chatContext(context: ChatContext)} {#snippet chatContext(context: ChatContext)}
<dl class="context" aria-label="Message context"> <dl class="context" aria-label="Message context">
<div> <div><dt>Match</dt><dd>{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(context.matchElapsedMs)}{/if}</dd></div>
<dt>Real time</dt> <div><dt>Round</dt><dd>{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}</dd></div>
<dd>{#if context.realTime}<time datetime={context.realTime} title={`Estimated real time: ${$timeFormat.dateTime(context.realTime)}`}>≈ {$timeFormat.time(context.realTime)}</time>{:else}—{/if}</dd>
</div>
<div><dt>Match</dt><dd>{context.matchElapsedMs === null ? '—' : formatDuration(context.matchElapsedMs)}</dd></div>
<div><dt>Round</dt><dd>{context.round ?? '—'}</dd></div>
</dl> </dl>
{/snippet} {/snippet}
{#snippet matchBoundary(entry: ChatBoundaryEntry)}
<div class="match-divider">
<div class="match-label">
<span>{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match'}</span>
{#if entry.context.realTime}<span class="boundary-context">{@render clockTime(entry.context.realTime)}</span>{/if}
{#if entry.context.matchElapsedMs !== null}<span class="boundary-context">Match <span class="number">{formatDuration(entry.context.matchElapsedMs)}</span></span>{/if}
{#if entry.context.round !== null}<span class="boundary-context">Round <span class="number">{entry.context.round}</span></span>{/if}
</div>
</div>
{/snippet}
<section aria-label="Player chat"> <section aria-label="Player chat">
<div class="chat-head"> <div class="chat-head">
<div><h2>Chat</h2><p>Recent messages from the game log · refreshes every 5 seconds</p></div> <div class="chat-title"><h2>Chat</h2><span class="update-cadence">Updates every 5s</span></div>
<div class="actions"> <div class="actions">
{#if !follow}<Button size="sm" onclick={latest}>Latest messages</Button>{/if} {#if !follow}<Button size="sm" variant="ghost" onclick={latest}>Latest messages</Button>{/if}
<Button size="sm" onclick={refresh} disabled={loading}>{loading ? 'Refreshing…' : 'Refresh'}</Button> <Button size="sm" onclick={() => refresh(true)} loading={manualRefreshing} ariaLabel={manualRefreshing ? 'Refreshing chat' : 'Refresh chat'}>Refresh</Button>
</div> </div>
</div> </div>
{#if error}<p class="ui-error" role="alert">{error} {loaded ? 'Showing the last loaded messages.' : ''}</p>{/if} {#if error}<p class="ui-error" role="alert">{error} {loaded ? 'Showing the last loaded messages.' : ''}</p>{/if}
@@ -80,46 +102,78 @@
<!-- svelte-ignore a11y_no_noninteractive_tabindex (The scrollable chat region must support keyboard scrolling.) --> <!-- svelte-ignore a11y_no_noninteractive_tabindex (The scrollable chat region must support keyboard scrolling.) -->
<div class="messages" bind:this={body} onscroll={onScroll} tabindex="0" role="region" aria-label="Chat messages"> <div class="messages" bind:this={body} onscroll={onScroll} tabindex="0" role="region" aria-label="Chat messages">
<ol> <ol>
{#each entries as entry} {#each timeline as item}
{#if entry.type === 'boundary'} {#if item.type === 'boundaries'}
<li class="match-divider" class:match-end={entry.event === 'end'}> <li class="match-events">
<div class="match-label"> {#if item.entries.length > 1}
<span>{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'}</span> {#key JSON.stringify(item.entries)}
{@render chatContext(entry.context)} <details class="earlier-events" ontoggle={(event) => { if (event.currentTarget.open) follow = false; }}>
</div> <summary>{item.entries.length - 1} earlier match {item.entries.length === 2 ? 'event' : 'events'}</summary>
<ol aria-label="Earlier match events">
{#each item.entries.slice(0, -1) as entry}
<li>{@render matchBoundary(entry)}</li>
{/each}
</ol>
</details>
{/key}
{/if}
{@render matchBoundary(item.entries[item.entries.length - 1])}
</li> </li>
{:else} {:else}
<li class="message"> <li class="message">
<div class="message-head"><strong>{entry.message.player}</strong>{@render chatContext(entry.context)}</div> <span class="message-clock">
<p>{entry.message.message}</p> {#if item.entry.context.realTime}{@render clockTime(item.entry.context.realTime)}{:else}<span class="sr-only">Time: </span>{@render unavailable()}{/if}
</span>
<div class="message-content">
<div class="message-head"><strong>{item.entry.message.player}</strong>{@render chatContext(item.entry.context)}</div>
<p>{item.entry.message.message}</p>
</div>
</li> </li>
{/if} {/if}
{/each} {/each}
</ol> </ol>
</div> </div>
{/if} {/if}
<p class="note">Real times are estimated from log writes. — means context is unavailable. Older messages may fall outside the recent log window.</p>
</section> </section>
<style> <style>
.chat-head, .actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } section { container-type: inline-size; }
.chat-head, .actions, .chat-title { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.chat-head { justify-content: space-between; }
.chat-title { gap: var(--space-2) var(--space-3); }
h2 { margin: 0; font-size: var(--text-lg); } h2 { margin: 0; font-size: var(--text-lg); }
.chat-head p, .note, .empty { color: var(--text-secondary); font-size: var(--text-sm); } .update-cadence { color: var(--text-secondary); font-size: var(--text-xs); }
.messages { max-height: 60vh; overflow: auto; margin-top: 16px; } .empty { color: var(--text-secondary); font-size: var(--text-sm); padding: var(--space-6) 0; }
.messages { max-height: 60vh; overflow: auto; margin-top: var(--space-4); }
.messages:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--accent-primary); border-radius: var(--radius-sm); }
ol { list-style: none; padding: 0; margin: 0; } ol { list-style: none; padding: 0; margin: 0; }
.message { padding: 12px 0; border-bottom: 1px solid var(--border-subtle); } .message { display: grid; grid-template-columns: 6rem minmax(0, 1fr); align-items: baseline; column-gap: var(--space-4); padding: var(--space-3) 0; }
.message-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px 20px; } .message-clock, .number { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.context { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; margin: 0; font-size: var(--text-sm); font-weight: 400; } .message-clock { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; }
.context > div { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; } .message-content { min-width: 0; }
.context dt { color: var(--text-tertiary); } .message-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); }
.context dd { margin: 0; color: var(--text-secondary); font-family: var(--font-mono); font-variant-numeric: tabular-nums; } strong { min-width: 0; color: var(--text-primary); font-size: var(--text-base); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
.match-divider { display: flex; align-items: center; gap: 12px; padding: 20px 0; color: var(--text-primary); font-size: var(--text-sm); } .context { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--space-1) var(--space-3); margin: 0; color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--weight-regular); }
.match-divider::before, .match-divider::after { content: ''; height: 1px; flex: 1; background: var(--border-subtle); } .context > div { display: flex; align-items: baseline; gap: var(--space-1); white-space: nowrap; }
.match-label { display: flex; align-items: center; flex-direction: column; gap: 6px; max-width: 100%; padding: 8px 14px; border: 1px solid var(--border-subtle); border-radius: 12px; font-weight: 600; text-align: center; } .context > div + div::before { content: '·'; margin-right: var(--space-1); color: var(--text-tertiary); }
.match-label .context { justify-content: center; } .context dd { margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.match-end { color: var(--text-secondary); } .message p { margin: var(--space-1) 0 0; font-size: var(--text-base); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
strong { color: var(--text-primary); overflow-wrap: anywhere; } .match-events { padding: var(--space-1) 0; }
li p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; } .match-divider { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; color: var(--text-secondary); font-size: var(--text-xs); }
.empty { padding: 24px 0; } .match-divider::before, .match-divider::after { content: ''; height: 1px; flex: 1; min-width: var(--space-3); background: var(--border-subtle); }
.note { margin-bottom: 0; } .match-label { min-width: 0; display: flex; align-items: baseline; justify-content: center; flex-wrap: wrap; gap: var(--space-1) var(--space-2); }
.boundary-context { white-space: nowrap; }
.boundary-context::before { content: '·'; margin-right: var(--space-2); color: var(--text-tertiary); }
.earlier-events summary { width: fit-content; max-width: 100%; box-sizing: border-box; min-height: 32px; margin-inline: auto; padding: var(--space-2); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: var(--text-xs); cursor: pointer; }
.earlier-events summary:hover { color: var(--text-primary); background: var(--surface-raised); }
.earlier-events summary:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@container (max-width: 560px) {
.message { grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-1) var(--space-3); }
.message-clock { grid-column: 2; grid-row: 1; }
.message-content, .message-head { display: contents; }
strong { grid-column: 1; grid-row: 1; }
.context { grid-column: 1 / -1; grid-row: 2; }
.message p { grid-column: 1 / -1; grid-row: 3; margin-top: 0; }
}
</style> </style>
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { groupChatTimeline, type ChatBoundaryEntry, type ChatMessageEntry } from '../../src/lib/ui/chat-timeline.js';
const boundary = (event: ChatBoundaryEntry['event'], time: string | null): ChatBoundaryEntry => ({
type: 'boundary', event, time, context: { realTime: null, matchElapsedMs: null, round: null }
});
const message = (text: string, round: number | null = 7): ChatMessageEntry => ({
type: 'message',
message: { time: '12:08', guid: 'abc', slot: 0, player: 'Player', message: text },
context: { realTime: '2026-10-02T12:16:04Z', matchElapsedMs: 728000, round }
});
describe('chat timeline display groups', () => {
it('retains every lifecycle event in log order, including repeated starts, resets and unknown times', () => {
const entries = Object.freeze([
boundary('end', '80:00'), boundary('start', null), boundary('start', '0:00'),
boundary('reset', '0:00'), boundary('end', null)
]);
const timeline = groupChatTimeline(entries);
expect(timeline).toEqual([{ type: 'boundaries', entries }]);
if (timeline[0].type !== 'boundaries') throw new Error('Expected lifecycle group');
timeline[0].entries.forEach((entry, index) => expect(entry).toBe(entries[index]));
});
it('never combines lifecycle runs separated by messages, including leading and trailing runs', () => {
const first = [boundary('start', null), boundary('reset', '0:00')];
const middle = [boundary('end', '12:08'), boundary('start', '0:00')];
const last = [boundary('end', null)];
const hello = message('hello');
const goodbye = message('goodbye');
expect(groupChatTimeline([...first, hello, ...middle, goodbye, ...last])).toEqual([
{ type: 'boundaries', entries: first },
{ type: 'message', entry: hello },
{ type: 'boundaries', entries: middle },
{ type: 'message', entry: goodbye },
{ type: 'boundaries', entries: last }
]);
});
it('preserves consecutive and repeated messages with their individual contexts', () => {
const entries = Object.freeze([message('hello', 7), message('hello', 8), message('later', null)]);
const timeline = groupChatTimeline(entries);
expect(timeline).toEqual(entries.map(entry => ({ type: 'message', entry })));
timeline.forEach((item, index) => {
if (item.type !== 'message') throw new Error('Expected message');
expect(item.entry).toBe(entries[index]);
expect(item.entry.context).toBe(entries[index].context);
});
});
});