Capture team chat and expose moderation match archives

This commit is contained in:
2026-10-17 20:11:49 -04:00
parent b678de2f6f
commit 870f13b150
28 changed files with 943 additions and 81 deletions
+15 -2
View File
@@ -1,8 +1,13 @@
import { readMatchQuery } from '$lib/match-history-query.js';
import { json } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
import { authError } from '$lib/server/auth/http.js';
export function GET({ url }: { url: URL }) {
export function GET({ url, locals }: { url: URL; locals?: MatchArchiveLocals }) {
const limit = Number(url.searchParams.get('limit') ?? 25);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
@@ -13,5 +18,13 @@ export function GET({ url }: { url: URL }) {
} catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 });
}
return json(serverMatchHistory(undefined, limit, query));
try {
if (query.archive) requireMatchArchiveAccess(locals?.user);
return json(
serverMatchHistory(undefined, limit, query),
query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined
);
} catch (cause) {
return authError(cause);
}
}
@@ -1,8 +1,21 @@
import { readMatchQuery } from '$lib/match-history-query.js';
import { json } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
import { authError } from '$lib/server/auth/http.js';
export function GET({ params, url }: { params: { name: string }; url: URL }) {
export function GET({
params,
url,
locals
}: {
params: { name: string };
url: URL;
locals?: MatchArchiveLocals;
}) {
const limit = Number(url.searchParams.get('limit') ?? 25);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
@@ -13,5 +26,13 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) {
} catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 });
}
return json(serverMatchHistory(params.name, limit, query));
try {
if (query.archive) requireMatchArchiveAccess(locals?.user);
return json(
serverMatchHistory(params.name, limit, query),
query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined
);
} catch (cause) {
return authError(cause);
}
}
+17 -1
View File
@@ -1,12 +1,28 @@
import { error } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.js';
export function load({ url }: { url: URL }) {
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({
url,
locals,
setHeaders
}: {
url: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query;
try {
query = readMatchQuery(matchApiParams(url));
} catch {
error(400, 'Invalid match history filters.');
}
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
return { history: serverMatchHistory(undefined, matchPageSize, query) };
}
+12 -2
View File
@@ -2,12 +2,22 @@
let { data }: { data: import('./$types').PageData } = $props();
import MatchHistory from '$ui/components/MatchHistory.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import { page } from '$app/stores';
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
</script>
<svelte:head><title>Matches · Electric Cherry</title></svelte:head>
<svelte:head
><title>{archive ? 'Moderation match archive' : 'Matches'} · Electric Cherry</title
></svelte:head
>
<div class="page">
<SectionHeader title="Matches" subtitle="Recorded matches across your servers." />
<SectionHeader
title={archive ? 'Moderation match archive' : 'Matches'}
subtitle={archive
? 'Retained match reports and chat for evidence review.'
: 'Recorded matches across your servers.'}
/>
<MatchHistory initial={data.history} />
</div>
+27 -2
View File
@@ -1,9 +1,34 @@
import { error } from '@sveltejs/kit';
import { getMatchDetail } from '$lib/server/fleet/match-history.js';
import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({ params }: { params: { id: string } }) {
export function load({
params,
url,
locals,
setHeaders
}: {
params: { id: string };
url?: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query;
try {
query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams());
} catch {
error(400, 'Invalid match history filters.');
}
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found');
const match = getMatchDetail(Number(params.id));
const match = getMatchDetail(Number(params.id), query.archive);
if (!match) error(404, 'Match not found');
return { match };
}
+79 -24
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { timestampISO } from '$lib/time-zone.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { uiNow } from '$ui/stores/servers.js';
import { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import type { MatchDetail } from '$lib/match-history.js';
@@ -16,6 +20,27 @@
let { data }: { data: { match: MatchDetail } } = $props();
const match = $derived(data.match);
const completed = $derived(match.status === 'completed');
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
const reporting = $derived(
match.status === 'active' &&
!match.server_deleted_at &&
freshStatusTimestamp(
timestampISO(match.lastReportedAt) ?? null,
browser ? $uiNow : Date.now()
)
);
const status = $derived(
completed
? 'Completed'
: match.status === 'active'
? reporting
? 'In progress'
: 'Not reporting'
: match.status === 'end-report-missing'
? 'End report missing'
: 'Interrupted'
);
const gameName = $derived(
match.game === 't5'
? 'Black Ops I'
@@ -45,22 +70,27 @@
<SectionHeader
title={match.map}
eyebrow={`Match #${match.id}`}
subtitle={`${gameName} · Recorded match results`}
subtitle={`${gameName} · ${completed ? 'Recorded match results' : 'Recorded match archive'}`}
>
{#snippet crumbs()}<Breadcrumbs
items={[
{ label: 'Match History', href: '/matches' },
{
label: archive ? 'Moderation archive' : 'Match History',
href: archive ? '/matches?matchArchive=1' : '/matches'
},
{ label: `Match #${match.id}` }
]}
/>{/snippet}
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
{#snippet actions()}<Badge variant={completed || reporting ? 'online' : 'warning'} size="md"
>{status}</Badge
>{/snippet}
</SectionHeader>
<section class="summary" aria-label="Match summary">
<section class="summary" class:unfinished={!completed} aria-label="Match summary">
<Card
><Stat
label="Final round"
value={match.finalRound}
label={completed ? 'Final round' : 'Highest observed round'}
value={completed ? match.finalRound : match.observedRound}
tone="accent"
size="lg"
tween={false}
@@ -68,7 +98,7 @@
>
<Card
><Stat
label="Game time"
label={completed ? 'Game time' : 'Recorded duration'}
value={match.durationMs === null ? null : formatDuration(match.durationMs)}
size="lg"
tween={false}
@@ -82,8 +112,14 @@
tween={false}
/></Card
>
<Card><Stat label="Players at end" value={match.endPlayers} size="lg" tween={false} /></Card
>
{#if completed}<Card
><Stat
label="Players at end"
value={match.endPlayers}
size="lg"
tween={false}
/></Card
>{/if}
</section>
<Card padding="lg">
@@ -104,9 +140,12 @@
<dd><RelativeTime value={match.startedAt} /></dd>
</div>
<div>
<dt>Ended</dt>
<dt>{completed ? 'Ended' : 'Last reported'}</dt>
<dd>
{#if match.endedAt}<RelativeTime value={match.endedAt} />{:else}—{/if}
<RelativeTime
value={completed ? match.endedAt : match.lastReportedAt}
missing="Not recorded"
/>
</dd>
</div>
<div>
@@ -119,13 +158,19 @@
game time is unknown.
</p>{/if}
</Card>
{#if !completed}<p class="note">
This match has no completed results. The archive preserves observed participants,
reported stats, and retained chat for review.
</p>{/if}
<section class="results" aria-labelledby="players-title">
<div class="results-heading">
<h2 id="players-title">Player results</h2>
<h2 id="players-title">{completed ? 'Player results' : 'Recorded participants'}</h2>
<p>
Every recorded player, including those absent from the final report. Stats are the
highest reported counters for this match.
{completed
? 'Every recorded player, including those absent from the final report.'
: 'Every participant recorded before reporting stopped or in the latest available reports.'}
Stats are the highest reported counters for this match.
{#if hasScoreTotals}
Total score is the highest recorded cumulative points earned in this match.
{:else}
@@ -163,10 +208,15 @@
<small class="guid">{player.guid}</small>
</th>
<td class="participation"
><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}
>{player.presentAtEnd
? 'At match end'
: 'Not at match end'}</Badge
><Badge
variant={completed && player.presentAtEnd
? 'online'
: 'neutral'}
>{!completed
? 'Recorded participant'
: player.presentAtEnd
? 'At match end'
: 'Not at match end'}</Badge
>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td
>
{#each stats as stat}<td class:round={stat.key === 'highestRound'}
@@ -191,8 +241,8 @@
{#if hasScoreTotals}
<p>
Total score is the highest recorded cumulative points earned in this match. Players
absent from the final report retain their latest recorded total. A dash in Total
score means no total was recorded for that player.
{completed ? 'absent from the final report' : 'absent from later reports'} retain their
latest recorded total. A dash in Total score means no total was recorded for that player.
</p>
{:else}
<p>
@@ -203,9 +253,10 @@
{/if}
<p>
Highest round includes late joins. Rounds survived counts only observed round
progression while the player was present. “At match end” means the player appeared in
the final report; other players keep their earlier stats. A dash means no value was
recorded.
progression while the player was present. {#if completed}“At match end” means the player
appeared in the final report; other players keep their earlier stats.{:else}Participation
confirms a recorded report and does not establish who was present at the end.{/if} A dash
means no value was recorded.
</p>
<p>
Player names use the latest recorded identity. Times reflect when reports were received
@@ -240,6 +291,9 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
}
.summary.unfinished {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.details {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -341,7 +395,8 @@
color: var(--text-secondary);
}
@media (max-width: 800px) {
.summary {
.summary,
.summary.unfinished {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.details {
+1
View File
@@ -249,6 +249,7 @@
error={refreshError ? 'Updates paused' : ''}
/>
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
<Button href="/matches?matchArchive=1">Chat archive</Button>
{#if report.unreadCount}<Button href="/moderation?view=cases&review=updates"
>Your case updates ({report.unreadCount})</Button
>{/if}
@@ -317,7 +317,7 @@
{#if archive}<div class="source-context">
{#if archive.matchId}<a
href={contextHref(
`/matches/${archive.matchId}`,
`/matches/${archive.matchId}?matchArchive=1`,
$page.url
)}>View match #{archive.matchId}</a
>{/if}
+27 -1
View File
@@ -1,5 +1,31 @@
import { serverSnapshot } from '$lib/server/fleet/snapshot.js';
import { error } from '@sveltejs/kit';
import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({ params }: { params: { name: string } }) {
export function load({
params,
url,
locals,
setHeaders
}: {
params: { name: string };
url?: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query;
try {
query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams());
} catch {
error(400, 'Invalid match history filters.');
}
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
return { serverSnapshot: serverSnapshot(params.name) };
}