diff --git a/src/lib/ui/components/moderation/EvidenceMessages.svelte b/src/lib/ui/components/moderation/EvidenceMessages.svelte
index cfbd8cc..b3642ee 100644
--- a/src/lib/ui/components/moderation/EvidenceMessages.svelte
+++ b/src/lib/ui/components/moderation/EvidenceMessages.svelte
@@ -1,55 +1,102 @@
-
- Selected messages and surrounding context in archive order. Messages between separate selections
- may be omitted.
-
+
+
{context}
+
+ Highlighted lines are subject messages. Messages between separate selections may be omitted.
+
+
- {#each entries as entry}
- {#if entry.type === 'message'}
- {@const highlighted =
- !!entry.messageId && highlightedMessageIds.includes(entry.messageId)}
- {@const selected = !!entry.messageId && selectedMessageIds.includes(entry.messageId)}
-
-
- {entry.message.player}
- {#if entry.message.channel === 'team'}Team chat {/if}
- {#if highlighted}Subject message
- {:else if selected}Selected context
- {:else}Surrounding context {/if}
-
-
- GUID {entry.message.guid}
- {#if entry.context.realTime}Estimated time {$timeFormat.dateTime(entry.context.realTime)} {:else}Message time unknown {/if}
- Log {entry.message.time}
- {#if entry.context.round !== null}Round {entry.context.round} {/if}
-
- {entry.message.message}
- {#if entry.messageId}
- Message identifier {entry.messageId}
- {/if}
-
- {/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)}
+
+
+ {#if realTime}{$timeFormat.clockTime(realTime)} {:else}Unknown time {/if}
+
+ {entry.message.player}
+
+ {highlighted
+ ? 'Subject message: '
+ : selected
+ ? 'Selected context: '
+ : 'Surrounding context: '} {#if entry.message.channel === 'team'}Team chat {' '}{/if}{entry
+ .message.message}
+
+
{/each}
+
+ Message details
+
+ {#each messages as entry}
+
+ {entry.message.player}
+ GUID {entry.message.guid}
+ Original log time · {entry.message.time}
+ {#if Number.isInteger(entry.context.round)}Round {entry.context.round} {/if}
+ {#if entry.messageId}Message identifier {entry.messageId} {/if}
+
+ {/each}
+
+
diff --git a/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte b/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte
index 51c5e30..55ca1e8 100644
--- a/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte
+++ b/src/lib/ui/components/moderation/MatchEvidenceDialog.svelte
@@ -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.
-
+
{#if error}{error}
{/if}
{#if uncertain}
diff --git a/src/lib/ui/moderation-evidence.ts b/src/lib/ui/moderation-evidence.ts
index 60c4127..8a415ef 100644
--- a/src/lib/ui/moderation-evidence.ts
+++ b/src/lib/ui/moderation-evidence.ts
@@ -30,8 +30,8 @@ export function preservedConversation(value: unknown): {
} | null {
if (!value || typeof value !== 'object') return null;
const snapshot = value as Record;
- 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 => {
if (!entry || typeof entry !== 'object' || entry.type !== 'message') return false;
return (
diff --git a/src/routes/moderation/cases/[id]/+page.svelte b/src/routes/moderation/cases/[id]/+page.svelte
index a6ef59b..411659d 100644
--- a/src/routes/moderation/cases/[id]/+page.svelte
+++ b/src/routes/moderation/cases/[id]/+page.svelte
@@ -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)}
- {archive?.player ?? 'Archived message'} {#if archive?.round !== null && archive?.round !== undefined}Round {archive.round} {/if}
+ {archive?.player ?? 'Archived conversation'}
{#if archive?.excerpt}
-
+
{:else}{evidence.text} {/if}
- {evidence.source}
- {#if archive}
- {#if archive.matchId}
View match #{archive.matchId} {/if}
- {#if archive.realTime}
Estimated message time · {$timeFormat.dateTime(
- archive.realTime
- )} {/if}
- {#if archive.time}
Original log time · {archive.time} {/if}
- {#if archive.startedAt}
Match started · {$timeFormat.dateTime(
- archive.startedAt
- )} {/if}
-
{/if}
+ {#if archive?.matchId}View match #{archive.matchId} {/if}
Preserved by {evidence.actor} ·
- {#if archive}
- Preserved archive snapshot
- {archive.original}
- {/if}
+
+ Technical details
+ {evidence.source}
+ {archive?.original ?? evidence.snapshot}
+
{/each}
@@ -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);
diff --git a/tests/client/match-evidence.test.ts b/tests/client/match-evidence.test.ts
index d18578f..aef6dbf 100644
--- a/tests/client/match-evidence.test.ts
+++ b/tests/client/match-evidence.test.ts
@@ -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(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"]')!;
expect(excerpt.querySelectorAll('li')).toHaveLength(3);
expect(excerpt.querySelector('.highlighted')?.textContent).toContain('A concerning reply');
diff --git a/tests/client/moderation-case-page.test.ts b/tests/client/moderation-case-page.test.ts
index 1e95047..0b5f8f6 100644
--- a/tests/client/moderation-case-page.test.ts
+++ b/tests/client/moderation-case-page.test.ts
@@ -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('Round 9');
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('Prior warning ignored');
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();
});
+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: 'First message ', 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('First message ');
+ 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 () => {
const updated = detail();
updated.case.revision = 5;