diff --git a/src/lib/time-zone.ts b/src/lib/time-zone.ts index d72b525..9f88bac 100644 --- a/src/lib/time-zone.ts +++ b/src/lib/time-zone.ts @@ -44,6 +44,7 @@ export function createTimeFormatter(zone: string) { dateTime: formatter({ ...date, ...time }), date: formatter(date), time: formatter(time), + clockTime: formatter({ hour: 'numeric', minute: '2-digit', second: '2-digit' }), minuteTime: formatter({ hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }), period: formatter({ ...date, hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }) }; diff --git a/src/lib/ui/chat-timeline.ts b/src/lib/ui/chat-timeline.ts new file mode 100644 index 0000000..5e9ab8e --- /dev/null +++ b/src/lib/ui/chat-timeline.ts @@ -0,0 +1,22 @@ +import type { ContextualChatEntry } from '../game-chat.js'; + +export type ChatMessageEntry = Extract; +export type ChatBoundaryEntry = Extract; +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; +} diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index d855e65..236c38a 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -2,24 +2,31 @@ import { onMount, tick } from 'svelte'; import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js'; import { formatDuration } from '$ui/duration.js'; + import { groupChatTimeline, type ChatBoundaryEntry } from '$ui/chat-timeline.js'; import { pollVisible } from '$ui/poll.js'; import { timeFormat } from '$ui/stores/time-zone.js'; import Button from './primitives/Button.svelte'; let { server }: { server: string } = $props(); let entries = $state([]); + let timeline = $derived(groupChatTimeline(entries)); let hasMessages = $derived(entries.some(entry => entry.type === 'message')); let loaded = $state(false); let loading = $state(false); + let manualRefreshing = $state(false); let error = $state(''); let missing = $state(false); let body = $state(null); let follow = $state(true); let controller: AbortController | null = null; - async function refresh() { - if (loading) return; + async function refresh(manual = false) { + if (loading) { + if (manual) manualRefreshing = true; + return; + } loading = true; + manualRefreshing = manual; const request = new AbortController(); controller = request; try { @@ -35,7 +42,7 @@ if (follow && body) body.scrollTop = body.scrollHeight; } catch (err) { if (!request.signal.aborted) error = err instanceof Error ? err.message : 'Could not refresh chat.'; - } finally { loading = false; } + } finally { loading = false; manualRefreshing = false; } } function onScroll() { @@ -47,28 +54,43 @@ if (body) body.scrollTop = body.scrollHeight; } onMount(() => { - const stop = pollVisible(refresh, 5000); + const stop = pollVisible(() => refresh(), 5000); return () => { stop(); controller?.abort(); }; }); +{#snippet unavailable()} + Unavailable +{/snippet} + +{#snippet clockTime(value: string)} + +{/snippet} + {#snippet chatContext(context: ChatContext)}
-
-
Real time
-
{#if context.realTime}{:else}—{/if}
-
-
Match
{context.matchElapsedMs === null ? '—' : formatDuration(context.matchElapsedMs)}
-
Round
{context.round ?? '—'}
+
Match
{#if context.matchElapsedMs === null}{@render unavailable()}{:else}{formatDuration(context.matchElapsedMs)}{/if}
+
Round
{#if context.round === null}{@render unavailable()}{:else}{context.round}{/if}
{/snippet} +{#snippet matchBoundary(entry: ChatBoundaryEntry)} +
+
+ {entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match'} + {#if entry.context.realTime}{@render clockTime(entry.context.realTime)}{/if} + {#if entry.context.matchElapsedMs !== null}Match {formatDuration(entry.context.matchElapsedMs)}{/if} + {#if entry.context.round !== null}Round {entry.context.round}{/if} +
+
+{/snippet} +
-

Chat

Recent messages from the game log · refreshes every 5 seconds

+

Chat

Updates every 5s
- {#if !follow}{/if} - + {#if !follow}{/if} +
{#if error}{/if} @@ -80,46 +102,78 @@
    - {#each entries as entry} - {#if entry.type === 'boundary'} -
  1. -
    - {entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'} - {@render chatContext(entry.context)} -
    + {#each timeline as item} + {#if item.type === 'boundaries'} +
  2. + {#if item.entries.length > 1} + {#key JSON.stringify(item.entries)} +
    { if (event.currentTarget.open) follow = false; }}> + {item.entries.length - 1} earlier match {item.entries.length === 2 ? 'event' : 'events'} +
      + {#each item.entries.slice(0, -1) as entry} +
    1. {@render matchBoundary(entry)}
    2. + {/each} +
    +
    + {/key} + {/if} + {@render matchBoundary(item.entries[item.entries.length - 1])}
  3. {:else}
  4. -
    {entry.message.player}{@render chatContext(entry.context)}
    -

    {entry.message.message}

    + + {#if item.entry.context.realTime}{@render clockTime(item.entry.context.realTime)}{:else}Time: {@render unavailable()}{/if} + +
    +
    {item.entry.message.player}{@render chatContext(item.entry.context)}
    +

    {item.entry.message.message}

    +
  5. {/if} {/each}
{/if} -

Real times are estimated from log writes. — means context is unavailable. Older messages may fall outside the recent log window.

diff --git a/tests/unit/ui-chat-timeline.test.ts b/tests/unit/ui-chat-timeline.test.ts new file mode 100644 index 0000000..a81fcf9 --- /dev/null +++ b/tests/unit/ui-chat-timeline.test.ts @@ -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); + }); + }); +});