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"> <script lang="ts">
import type { ContextualChatEntry } from '$lib/game-chat.js'; 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 { timeFormat } from '$ui/stores/time-zone.js';
import Badge from '../primitives/Badge.svelte'; import Badge from '../primitives/Badge.svelte';
let { let {
entries, entries,
selectedMessageIds = [], selectedMessageIds = [],
highlightedMessageIds = [] highlightedMessageIds = [],
matchId,
server,
startedAt
}: { }: {
entries: ContextualChatEntry[]; entries: ContextualChatEntry[];
selectedMessageIds?: string[]; selectedMessageIds?: string[];
highlightedMessageIds?: string[]; highlightedMessageIds?: string[];
matchId?: number | null;
server?: string | null;
startedAt?: string | null;
} = $props(); } = $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> </script>
<p class="excerpt-note"> <div class="excerpt-context">
Selected messages and surrounding context in archive order. Messages between separate selections <p>{context}</p>
may be omitted. <p>
</p> Highlighted lines are subject messages. Messages between separate selections may be omitted.
</p>
</div>
<ol class="excerpt" aria-label="Preserved conversation excerpt"> <ol class="excerpt" aria-label="Preserved conversation excerpt">
{#each entries as entry} {#each messages as entry}
{#if entry.type === 'message'} {@const highlighted = !!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
{@const highlighted = {@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
!!entry.messageId && highlightedMessageIds.includes(entry.messageId)} {@const realTime = timestampISO(entry.context.realTime)}
{@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)} <li class:highlighted>
<li class:highlighted> <span class="message-time">
<div class="author"> {#if realTime}<time datetime={realTime} title={$timeFormat.dateTime(realTime)}
<strong>{entry.message.player}</strong> >{$timeFormat.clockTime(realTime)}</time
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if} >{:else}<span title="Message time unknown">Unknown time</span>{/if}
{#if highlighted}<Badge variant="warning">Subject message</Badge> </span>
{:else if selected}<Badge>Selected context</Badge> <strong class="author">{entry.message.player}</strong>
{:else}<span>Surrounding context</span>{/if} <p class="message">
</div> <span class="sr-only"
<div class="metadata"> >{highlighted
<span>GUID {entry.message.guid}</span> ? 'Subject message: '
{#if entry.context.realTime}<span : selected
>Estimated time <time datetime={entry.context.realTime} ? 'Selected context: '
>{$timeFormat.dateTime(entry.context.realTime)}</time : 'Surrounding context: '}</span
></span >{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{' '}{/if}{entry
>{:else}<span>Message time unknown</span>{/if} .message.message}
<span>Log {entry.message.time}</span> </p>
{#if entry.context.round !== null}<span>Round {entry.context.round}</span>{/if} </li>
</div>
<p>{entry.message.message}</p>
{#if entry.messageId}<details>
<summary>Message identifier</summary><code>{entry.messageId}</code>
</details>{/if}
</li>
{/if}
{/each} {/each}
</ol> </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> <style>
.excerpt { .excerpt {
@@ -59,50 +106,63 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
li { .excerpt > li {
padding: var(--space-3); display: grid;
border-left: 3px solid var(--border-subtle); 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); background: var(--surface-sunken);
min-width: 0; min-width: 0;
font-size: var(--text-sm);
line-height: var(--leading-body);
} }
li.highlighted { .excerpt > li.highlighted {
border-left-color: var(--state-warning); border-left-color: var(--state-warning);
background: var(--surface-raised); background: var(--surface-raised);
} }
.author, .author,
.metadata { .message {
display: flex; min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.author > span, .message {
.metadata { white-space: pre-wrap;
color: var(--text-secondary);
font-size: var(--text-xs);
} }
.metadata { .message-time {
margin-top: var(--space-1); color: var(--text-secondary);
font-variant-numeric: tabular-nums;
} }
p { p {
white-space: pre-wrap; margin: 0;
overflow-wrap: anywhere;
margin: var(--space-2) 0 0;
line-height: var(--leading-body);
} }
details { .excerpt-context,
margin-top: var(--space-2); .message-details {
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--text-secondary); color: var(--text-secondary);
line-height: var(--leading-body);
overflow-wrap: anywhere; 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 { summary {
cursor: pointer; cursor: pointer;
} }
.excerpt-note { @media (max-width: 640px) {
margin: 0; .excerpt > li {
font-size: var(--text-xs); grid-template-columns: 7.5em minmax(0, 1fr);
color: var(--text-secondary); gap: var(--space-1) var(--space-3);
}
.message {
grid-column: 1 / -1;
}
} }
</style> </style>
@@ -250,7 +250,12 @@
{excerpt.length} messages in this excerpt. Selected subject messages are highlighted. {excerpt.length} messages in this excerpt. Selected subject messages are highlighted.
Messages already preserved as evidence in this case are not attached again. Messages already preserved as evidence in this case are not attached again.
</p> </p>
<EvidenceMessages entries={excerpt} {selectedMessageIds} {highlightedMessageIds} /> <EvidenceMessages
entries={excerpt}
{matchId}
{selectedMessageIds}
{highlightedMessageIds}
/>
</fieldset> </fieldset>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if} {#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if uncertain}<p class="hint"> {#if uncertain}<p class="hint">
+2 -2
View File
@@ -30,8 +30,8 @@ export function preservedConversation(value: unknown): {
} | null { } | null {
if (!value || typeof value !== 'object') return null; if (!value || typeof value !== 'object') return null;
const snapshot = value as Record<string, unknown>; const snapshot = value as Record<string, unknown>;
if (!Array.isArray(snapshot.entries)) return null; const candidates = Array.isArray(snapshot.entries) ? snapshot.entries : [snapshot.entry];
const entries = snapshot.entries.filter( const entries = candidates.filter(
(entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => { (entry): entry is Extract<ContextualChatEntry, { type: 'message' }> => {
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false; if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
return ( return (
+18 -47
View File
@@ -189,17 +189,6 @@
typeof value.entry?.message?.player === 'string' typeof value.entry?.message?.player === 'string'
? (value.entry.message.player as string) ? (value.entry.message.player as string)
: null, : 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), original: JSON.stringify(value, null, 2),
excerpt: preservedConversation(value) excerpt: preservedConversation(value)
}; };
@@ -302,45 +291,32 @@
{@const archive = archiveContext(evidence.snapshot)} {@const archive = archiveContext(evidence.snapshot)}
<li class="record"> <li class="record">
<div class="row"> <div class="row">
<strong>{archive?.player ?? 'Archived message'}</strong <strong>{archive?.player ?? 'Archived conversation'}</strong>
>{#if archive?.round !== null && archive?.round !== undefined}<Badge
>Round {archive.round}</Badge
>{/if}
</div> </div>
{#if archive?.excerpt} {#if archive?.excerpt}
<EvidenceMessages {...archive.excerpt} /> <EvidenceMessages
{...archive.excerpt}
matchId={archive.matchId}
server={archive.server}
startedAt={archive.startedAt}
/>
{:else}<blockquote>{evidence.text}</blockquote>{/if} {:else}<blockquote>{evidence.text}</blockquote>{/if}
<p class="hint">{evidence.source}</p> {#if archive?.matchId}<a
{#if archive}<div class="source-context"> href={contextHref(
{#if archive.matchId}<a `/matches/${archive.matchId}?matchArchive=1`,
href={contextHref( $page.url
`/matches/${archive.matchId}?matchArchive=1`, )}>View match #{archive.matchId}</a
$page.url >{/if}
)}>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}
<p class="hint"> <p class="hint">
Preserved by {evidence.actor} · <RelativeTime Preserved by {evidence.actor} · <RelativeTime
value={evidence.createdAt} value={evidence.createdAt}
/> />
</p> </p>
{#if archive}<details> <details>
<summary>Preserved archive snapshot</summary> <summary>Technical details</summary>
<pre>{archive.original}</pre> <p class="hint">{evidence.source}</p>
</details>{/if} <pre>{archive?.original ?? evidence.snapshot}</pre>
</details>
</li> </li>
{/each} {/each}
</ol> </ol>
@@ -694,15 +670,10 @@
} }
.hint, .hint,
dt, dt,
.source-context,
summary { summary {
color: var(--text-secondary); color: var(--text-secondary);
font-size: var(--text-sm); font-size: var(--text-sm);
} }
.source-context {
display: grid;
gap: var(--space-2);
}
.notice { .notice {
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
border-radius: var(--radius-md); border-radius: var(--radius-md);
+1 -1
View File
@@ -83,7 +83,7 @@ it('selects messages across participants, previews their context, and attaches t
); );
expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11'); expect(fetch.mock.calls[0][0]).toContain('game=t5&guid=11');
expect(document.querySelector('dialog')?.textContent).toContain('GUID 22'); expect(document.querySelector('dialog')?.textContent).toContain('GUID 22');
expect(document.querySelector('dialog')?.textContent).toContain('Message time unknown'); expect(document.querySelector('dialog')?.textContent).toContain('Unknown time');
const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!; const excerpt = document.querySelector('dialog [aria-label="Preserved conversation excerpt"]')!;
expect(excerpt.querySelectorAll('li')).toHaveLength(3); expect(excerpt.querySelectorAll('li')).toHaveLength(3);
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply'); expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
+64
View File
@@ -205,6 +205,18 @@ it('preserves original evidence context, linked restrictions, and staff history
expect(document.body.textContent).toContain('Read-only moderation access'); expect(document.body.textContent).toContain('Read-only moderation access');
expect(document.body.textContent).toContain('Round 9'); expect(document.body.textContent).toContain('Round 9');
expect(document.body.textContent).toContain('Original log time · 12:34'); expect(document.body.textContent).toContain('Original log time · 12:34');
const evidence = document.querySelector('[aria-labelledby="evidence-title"]')!;
expect(
evidence.querySelector('[aria-label="Preserved conversation excerpt"]')?.textContent
).toContain('abusive message');
const technical = [...evidence.querySelectorAll('details')].find(
(node) => node.querySelector('summary')?.textContent === 'Technical details'
)!;
expect(technical.open).toBe(false);
expect(technical.textContent).toContain('Archived chat · theater · match 17');
expect(JSON.parse(technical.querySelector('pre')!.textContent!)).toEqual(
JSON.parse(detail().case.evidence[0].snapshot!)
);
expect(document.body.textContent).toContain('Check the earlier warning.'); expect(document.body.textContent).toContain('Check the earlier warning.');
expect(document.body.textContent).toContain('Prior warning ignored'); expect(document.body.textContent).toContain('Prior warning ignored');
expect(document.body.textContent).toContain('Policy pending'); expect(document.body.textContent).toContain('Policy pending');
@@ -223,6 +235,58 @@ it('preserves original evidence context, linked restrictions, and staff history
expect(document.querySelector('dialog')).toBeNull(); expect(document.querySelector('dialog')).toBeNull();
}); });
it('summarizes a preserved conversation across midnight and keeps identifiers on demand', () => {
const initial = detail();
const archive = JSON.parse(initial.case.evidence[0].snapshot!);
archive.entries = [
{
...archive.entry,
messageId: 'subject-message',
context: { realTime: '2026-10-16T03:59:00Z', round: 4 },
message: { ...archive.entry.message, message: '<b>First message</b>', channel: 'team' }
},
{
...archive.entry,
messageId: 'witness-message',
context: { realTime: '2026-10-16T04:01:00Z', round: 9 },
message: { ...archive.entry.message, guid: '42', player: 'Witness', message: 'Reply' }
},
{
...archive.entry,
messageId: 'unknown-time',
context: { realTime: null, round: null }
}
];
archive.selectedMessageIds = ['subject-message', 'witness-message'];
archive.highlightedMessageIds = ['subject-message'];
initial.case.evidence[0].snapshot = JSON.stringify(archive);
show(initial);
const evidence = document.querySelector('[aria-labelledby="evidence-title"]')!;
const excerpt = evidence.querySelector('[aria-label="Preserved conversation excerpt"]')!;
const rows = excerpt.querySelectorAll('li');
expect(rows).toHaveLength(3);
expect(rows[0].classList.contains('highlighted')).toBe(true);
expect(rows[1].classList.contains('highlighted')).toBe(false);
expect(rows[0].textContent).toContain('Team chat');
expect(rows[0].textContent).toContain('<b>First message</b>');
expect(rows[0].querySelector('b')).toBeNull();
expect(rows[1].textContent).toContain('Selected context');
expect(rows[2].textContent).toContain('Unknown time');
expect(excerpt.textContent).not.toContain(guid);
expect(excerpt.textContent).not.toContain('subject-message');
expect(excerpt.textContent).not.toContain('Round');
const context = excerpt.previousElementSibling!.textContent!;
expect(context).toContain('Kino Der Toten · Match #17');
expect(context).toContain('Oct 15, 2026 – Oct 16, 2026');
expect(context).toContain('Rounds 4–9 (some unknown)');
expect(context).toContain('Times are estimates.');
const details = excerpt.nextElementSibling as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.textContent).toContain(`GUID ${guid}`);
expect(details.textContent).toContain('Message identifier subject-message');
expect(details.textContent).toContain('Message identifier witness-message');
});
it('locks case restrictions to the recorded identity and revision without closing the case', async () => { it('locks case restrictions to the recorded identity and revision without closing the case', async () => {
const updated = detail(); const updated = detail();
updated.case.revision = 5; updated.case.revision = 5;