analysis complete

This commit is contained in:
NextJS
2026-05-23 15:03:50 +00:00
parent 9bb0af59b0
commit 975cc5f1d9
7 changed files with 291 additions and 66 deletions
+17 -17
View File
@@ -411,6 +411,23 @@ export function AdminDashboard({
: null}
)
</Link>
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
analysisFrom,
analysisTo,
analysisPlayers:
analysisPlayerIds.length > 0
? analysisPlayerIds.join(",")
: null,
})}
className={tabClass(tab === "analysis")}
scroll={false}
>
Analysis
</Link>
<Link
href={buildDashboardHref({
tab: "matchmaker",
@@ -433,23 +450,6 @@ export function AdminDashboard({
>
Ledger
</Link>
<Link
href={buildDashboardHref({
tab: "analysis",
highlightId,
participantRaw,
analysisFrom,
analysisTo,
analysisPlayers:
analysisPlayerIds.length > 0
? analysisPlayerIds.join(",")
: null,
})}
className={tabClass(tab === "analysis")}
scroll={false}
>
Analysis
</Link>
<Link
href="/settings"
className={tabClass(false)}
+119 -14
View File
@@ -110,20 +110,13 @@ export function AdminMatchAnalysis({
n.toLocaleString("en-US", { maximumFractionDigits: 1 }),
);
const winnerRatioPct =
analysis.matchesWithLogFiles > 0
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
: null;
return (
<section className="space-y-6">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Aggregates gameplay metrics from per-match log files (
<span className="font-mono text-zinc-700 dark:text-zinc-300">
MATCH_LOGS_DIR
</span>
/{" "}
<span className="font-mono">&lt;matchId&gt;.txt</span>). Match length is
first-to-last log timestamp; each{" "}
<span className="font-mono">launching puck force</span> line counts as
one shot.
</p>
{analysis.configError ? (
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
@@ -276,20 +269,56 @@ export function AdminMatchAnalysis({
</span>
</div>
{analysis.mirrorExceptionDisconnectMatchIds.length > 0 ? (
<div
className="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-950 dark:border-red-800 dark:bg-red-950/50 dark:text-red-100"
role="alert"
>
<p className="font-medium">
Mirror exception disconnect (
{analysis.mirrorExceptionDisconnectMatchIds.length.toLocaleString(
"en-US",
)}{" "}
{analysis.mirrorExceptionDisconnectMatchIds.length === 1
? "match"
: "matches"}
)
</p>
<p className="mt-1 text-red-800 dark:text-red-200/90">
These matches include a{" "}
<span className="font-mono">[Mirror/Error]</span> line where a
player was disconnected because handling a command caused an
exception.
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{analysis.mirrorExceptionDisconnectMatchIds.map((matchId) => (
<li key={matchId}>
<Link
href={`/match-logs/${matchId}`}
className="inline-flex items-center rounded-md border border-red-400/80 bg-white px-2.5 py-1 font-mono text-xs font-medium text-red-900 shadow-sm hover:bg-red-100 dark:border-red-700 dark:bg-red-950/80 dark:text-red-50 dark:hover:bg-red-900/80"
>
Match {matchId}
</Link>
</li>
))}
</ul>
</div>
) : null}
<div className="grid gap-4 lg:grid-cols-3">
<StatBlock
title="Match length"
avg={durationFmt.avg}
min={durationFmt.min}
max={durationFmt.max}
hint="First to last log line (m:ss)"
hint="Ended matches only · first to last log line (m:ss)"
/>
<StatBlock
title="Shots per match"
avg={shotsFmt.avg}
min={shotsFmt.min}
max={shotsFmt.max}
hint="launching puck lines"
hint="Ended matches only · launching puck lines"
/>
<StatBlock
title="Shot force magnitude"
@@ -326,10 +355,86 @@ export function AdminMatchAnalysis({
rows={analysis.emojiEmotes}
title="Emoji emotes"
barClassName="fill-amber-500 dark:fill-amber-400"
labelWidth={72}
labelMaxLen={12}
labelMonospace={false}
/>
</div>
</div>
</div>
{analysis.matchesWithLogFiles > 0 ? (
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Match completion (from logs)
</h3>
<p className="mt-2 text-sm text-zinc-700 dark:text-zinc-300">
Winner logged:{" "}
<span className="font-mono font-semibold tabular-nums">
{analysis.matchesWithWinnerPatch.toLocaleString("en-US")}
</span>
{" / "}
<span className="font-mono tabular-nums">
{analysis.matchesWithLogFiles.toLocaleString("en-US")}
</span>
{winnerRatioPct != null ? (
<>
{" "}
(
<span className="font-mono tabular-nums">
{winnerRatioPct.toLocaleString("en-US", {
maximumFractionDigits: 1,
})}
%
</span>
)
</>
) : null}
</p>
<p className="mt-1 text-[11px] text-zinc-500 dark:text-zinc-400">
A match ended when its log contains{" "}
<span className="font-mono">
Dedicated match winner PATCH success
</span>
.
</p>
</div>
{analysis.notEndedMatchIds.length > 0 ? (
<div
className="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
<p className="font-medium">
Not ended (
{analysis.notEndedMatchIds.length.toLocaleString("en-US")}{" "}
{analysis.notEndedMatchIds.length === 1 ? "match" : "matches"})
</p>
<p className="mt-1 text-amber-900/90 dark:text-amber-200/90">
Log file present but no winner PATCH line the match did not
finish normally.
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{analysis.notEndedMatchIds.map((matchId) => (
<li key={matchId}>
<Link
href={`/match-logs/${matchId}`}
className="inline-flex items-center rounded-md border border-amber-400/80 bg-white px-2.5 py-1 font-mono text-xs font-medium text-amber-950 shadow-sm hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-50 dark:hover:bg-amber-900/80"
>
Match {matchId}
</Link>
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Every match with a log file includes a winner PATCH line.
</p>
)}
</section>
) : null}
</section>
);
}
+22 -8
View File
@@ -3,7 +3,6 @@
import type { EmoteBarRow } from "@/lib/match-log-parser";
const ROW_H = 22;
const LABEL_W = 120;
const BAR_MAX_W = 180;
const PAD = 4;
@@ -12,12 +11,21 @@ type Props = {
/** e.g. "Text emotes" */
title: string;
barClassName?: string;
/** Wider labels for emoji glyphs + `[id]` (default 120). */
labelWidth?: number;
/** Max label characters before ellipsis (default 16). */
labelMaxLen?: number;
/** Use monospace for label text (default true). */
labelMonospace?: boolean;
};
export function MatchAnalysisEmoteChart({
rows,
title,
barClassName = "fill-violet-500 dark:fill-violet-400",
labelWidth = 120,
labelMaxLen = 16,
labelMonospace = true,
}: Props) {
if (rows.length === 0) {
return (
@@ -29,7 +37,7 @@ export function MatchAnalysisEmoteChart({
const max = Math.max(...rows.map((r) => r.count), 1);
const vbH = PAD * 2 + rows.length * ROW_H;
const vbW = LABEL_W + BAR_MAX_W + 48;
const vbW = labelWidth + BAR_MAX_W + 48;
return (
<figure aria-label={`${title} bar chart`}>
@@ -42,8 +50,8 @@ export function MatchAnalysisEmoteChart({
const y = PAD + i * ROW_H + ROW_H / 2;
const barW = (row.count / max) * BAR_MAX_W;
const label =
row.label.length > 16
? `${row.label.slice(0, 15)}`
row.label.length > labelMaxLen
? `${row.label.slice(0, labelMaxLen - 1)}`
: row.label;
return (
<g key={row.label}>
@@ -51,13 +59,19 @@ export function MatchAnalysisEmoteChart({
x={0}
y={y}
dominantBaseline="middle"
className="fill-zinc-600 text-[9px] dark:fill-zinc-400"
style={{ fontFamily: "ui-monospace, monospace" }}
className={`fill-zinc-600 dark:fill-zinc-400 ${
labelMonospace ? "text-[9px]" : "text-[11px]"
}`}
style={
labelMonospace
? { fontFamily: "ui-monospace, monospace" }
: undefined
}
>
{label}
</text>
<rect
x={LABEL_W}
x={labelWidth}
y={y - 6}
width={barW}
height={12}
@@ -65,7 +79,7 @@ export function MatchAnalysisEmoteChart({
className={barClassName}
/>
<text
x={LABEL_W + BAR_MAX_W + 6}
x={labelWidth + BAR_MAX_W + 6}
y={y}
dominantBaseline="middle"
className="fill-zinc-500 text-[9px] tabular-nums dark:fill-zinc-400"