Compact archived evidence transcripts (Refs #159)
CI / checks (push) Failing after 7m22s

This commit is contained in:
2026-10-18 16:20:29 -04:00
parent a7f1c65d17
commit f2b2541b46
6 changed files with 210 additions and 110 deletions
@@ -1,55 +1,102 @@
<script lang="ts">
import type { ContextualChatEntry } from '$lib/game-chat.js';
import { timestampISO } from '$lib/time-zone.js';
import { serverIdentity } from '$ui/server-labels.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte';
let {
entries,
selectedMessageIds = [],
highlightedMessageIds = []
highlightedMessageIds = [],
matchId,
server,
startedAt
}: {
entries: ContextualChatEntry[];
selectedMessageIds?: string[];
highlightedMessageIds?: string[];
matchId?: number | null;
server?: string | null;
startedAt?: string | null;
} = $props();
const messages = $derived(entries.filter((entry) => entry.type === 'message'));
const context = $derived.by(() => {
const times = messages
.map((entry) => timestampISO(entry.context.realTime))
.filter((time): time is string => !!time)
.sort();
const dates = [...new Set(times.map((time) => $timeFormat.date(time)))];
const rounds = messages
.map((entry) => entry.context.round)
.filter((round): round is number => Number.isInteger(round));
const firstRound = Math.min(...rounds);
const lastRound = Math.max(...rounds);
return [
...(server ? [serverIdentity(server).map] : []),
...(matchId ? [`Match #${matchId}`] : []),
dates.length
? dates.length === 1
? dates[0]
: `${dates[0]} – ${dates[dates.length - 1]}`
: timestampISO(startedAt)
? `Match started ${$timeFormat.date(startedAt)}`
: 'Date not recorded',
rounds.length
? `${firstRound === lastRound ? `Round ${firstRound}` : `Rounds ${firstRound}–${lastRound}`}${rounds.length < messages.length ? ' (some unknown)' : ''}`
: 'Round not recorded',
'Times are estimates.'
].join(' · ');
});
</script>
<p class="excerpt-note">
Selected messages and surrounding context in archive order. Messages between separate selections
may be omitted.
</p>
<div class="excerpt-context">
<p>{context}</p>
<p>
Highlighted lines are subject messages. Messages between separate selections may be omitted.
</p>
</div>
<ol class="excerpt" aria-label="Preserved conversation excerpt">
{#each entries as entry}
{#if entry.type === 'message'}
{@const highlighted =
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
<li class:highlighted>
<div class="author">
<strong>{entry.message.player}</strong>
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if}
{#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if}
</div>
<div class="metadata">
<span>GUID {entry.message.guid}</span>
{#if entry.context.realTime}<span
>Estimated time <time datetime={entry.context.realTime}
>{$timeFormat.dateTime(entry.context.realTime)}</time
></span
>{:else}<span>Message time unknown</span>{/if}
<span>Log {entry.message.time}</span>
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if}
</div>
<p>{entry.message.message}</p>
{#if entry.messageId}<details>
<summary>Message identifier</summary><code>{entry.messageId}</code>
</details>{/if}
</li>
{/if}
{#each messages as entry}
{@const highlighted = !!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
{@const realTime = timestampISO(entry.context.realTime)}
<li class:highlighted>
<span class="message-time">
{#if realTime}<time datetime={realTime} title={$timeFormat.dateTime(realTime)}
>{$timeFormat.clockTime(realTime)}</time
>{:else}<span title="Message time unknown">Unknown time</span>{/if}
</span>
<strong class="author">{entry.message.player}</strong>
<p class="message">
<span class="sr-only"
>{highlighted
? 'Subject message: '
: selected
? 'Selected context: '
: 'Surrounding context: '}</span
>{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{' '}{/if}{entry
.message.message}
</p>
</li>
{/each}
</ol>
<details class="message-details">
<summary>Message details</summary>
<ol aria-label="Preserved message details">
{#each messages as entry}
<li>
<strong>{entry.message.player}</strong>
<span>GUID <code>{entry.message.guid}</code></span>
<span>Original log time · {entry.message.time}</span>
{#if Number.isInteger(entry.context.round)}<span>Round {entry.context.round}</span
>{/if}
{#if entry.messageId}<span>Message identifier <code>{entry.messageId}</code></span
>{/if}
</li>
{/each}
</ol>
</details>
<style>
.excerpt {
@@ -59,50 +106,63 @@
margin: 0;
padding: 0;
}
li {
padding: var(--space-3);
border-left: 3px solid var(--border-subtle);
.excerpt > li {
display: grid;
grid-template-columns: 7.5em minmax(6em, 1fr) minmax(0, 3fr);
align-items: baseline;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-left: 3px solid transparent;
background: var(--surface-sunken);
min-width: 0;
font-size: var(--text-sm);
line-height: var(--leading-body);
}
li.highlighted {
.excerpt > li.highlighted {
border-left-color: var(--state-warning);
background: var(--surface-raised);
}
.author,
.metadata {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
.message {
min-width: 0;
overflow-wrap: anywhere;
}
.author > span,
.metadata {
color: var(--text-secondary);
font-size: var(--text-xs);
.message {
white-space: pre-wrap;
}
.metadata {
margin-top: var(--space-1);
.message-time {
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
p {
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: var(--space-2) 0 0;
line-height: var(--leading-body);
margin: 0;
}
details {
margin-top: var(--space-2);
.excerpt-context,
.message-details {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: var(--leading-body);
overflow-wrap: anywhere;
}
.message-details ol {
padding-left: var(--space-5);
}
.message-details li {
padding: var(--space-2) 0;
}
.message-details li > span {
display: block;
}
summary {
cursor: pointer;
}
.excerpt-note {
margin: 0;
font-size: var(--text-xs);
color: var(--text-secondary);
@media (max-width: 640px) {
.excerpt > li {
grid-template-columns: 7.5em minmax(0, 1fr);
gap: var(--space-1) var(--space-3);
}
.message {
grid-column: 1 / -1;
}
}
</style>
@@ -250,7 +250,12 @@
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
Messages already preserved as evidence in this case are not attached again.
</p>
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} />
<EvidenceMessages
entries={excerpt}
{matchId}
{selectedMessageIds}
{highlightedMessageIds}
/>
</fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint">
+2 -2
View File
@@ -30,8 +30,8 @@ export function preservedConversation(value: unknown): {
} | null {
if (!value || typeof value !== 'object') return null;
const snapshot = value as Record<string, unknown>;
if (!Array.isArray(snapshot.entries)) return null;
const entries = snapshot.entries.filter(
const candidates = Array.isArray(snapshot.entries) ? snapshot.entries : [snapshot.entry];
const entries = candidates.filter(
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
return (
+18 -47
View File
@@ -189,17 +189,6 @@
typeof value.entry?.message?.player === 'string'
? (value.entry.message.player as string)
: null,
time:
typeof value.entry?.message?.time === 'string'
? (value.entry.message.time as string)
: null,
realTime:
typeof value.entry?.context?.realTime === 'string'
? (value.entry.context.realTime as string)
: null,
round: Number.isInteger(value.entry?.context?.round)
? (value.entry.context.round as number)
: null,
original: JSON.stringify(value, null, 2),
excerpt: preservedConversation(value)
};
@@ -302,45 +291,32 @@
{@const archive = archiveContext(evidence.snapshot)}
<li class="record">
<div class="row">
<strong>{archive?.player ?? 'Archived message'}</strong
>{#if archive?.round !== null && archive?.round !== undefined}<Badge
>Round {archive.round}</Badge
>{/if}
<strong>{archive?.player ?? 'Archived conversation'}</strong>
</div>
{#if archive?.excerpt}
<EvidenceMessages {...archive.excerpt} />
<EvidenceMessages
{...archive.excerpt}
matchId={archive.matchId}
server={archive.server}
startedAt={archive.startedAt}
/>
{:else}<blockquote>{evidence.text}</blockquote>{/if}
<p class="hint">{evidence.source}</p>
{#if archive}<div class="source-context">
{#if archive.matchId}<a
href={contextHref(
`/matches/${archive.matchId}?matchArchive=1`,
$page.url
)}>View match #{archive.matchId}</a
>{/if}
{#if archive.realTime}<span
>Estimated message time · {$timeFormat.dateTime(
archive.realTime
)}</span
>{/if}
{#if archive.time}<span
>Original log time · {archive.time}</span
>{/if}
{#if archive.startedAt}<span
>Match started · {$timeFormat.dateTime(
archive.startedAt
)}</span
>{/if}
</div>{/if}
{#if archive?.matchId}<a
href={contextHref(
`/matches/${archive.matchId}?matchArchive=1`,
$page.url
)}>View match #{archive.matchId}</a
>{/if}
<p class="hint">
Preserved by {evidence.actor} · <RelativeTime
value={evidence.createdAt}
/>
</p>
{#if archive}<details>
<summary>Preserved archive snapshot</summary>
<pre>{archive.original}</pre>
</details>{/if}
<details>
<summary>Technical details</summary>
<p class="hint">{evidence.source}</p>
<pre>{archive?.original ?? evidence.snapshot}</pre>
</details>
</li>
{/each}
</ol>
@@ -694,15 +670,10 @@
}
.hint,
dt,
.source-context,
summary {
color: var(--text-secondary);
font-size: var(--text-sm);
}
.source-context {
display: grid;
gap: var(--space-2);
}
.notice {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);