analysis complete

This commit is contained in:
NextJS
2026-05-23 15:40:53 +00:00
parent 975cc5f1d9
commit dc8377177a
3 changed files with 323 additions and 57 deletions
+16 -57
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useMemo } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
@@ -269,41 +270,19 @@ 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">
<AnalysisMatchAlertBox
category="mirror"
matchIds={analysis.mirrorExceptionDisconnectMatchIds}
title="Mirror exception disconnect"
description={
<>
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
@@ -402,32 +381,12 @@ export function AdminMatchAnalysis({
</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>
<AnalysisMatchAlertBox
category="notEnded"
matchIds={analysis.notEndedMatchIds}
title="Not ended"
description="Log file present but no winner PATCH line — the match did not finish normally."
/>
) : (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
Every match with a log file includes a winner PATCH line.
+178
View File
@@ -0,0 +1,178 @@
"use client";
import Link from "next/link";
import {
useCallback,
useEffect,
useMemo,
useState,
type MouseEvent,
type ReactNode,
} from "react";
import {
EMPTY_HIDDEN_MIRROR,
EMPTY_HIDDEN_NOT_ENDED,
getHiddenAnalysisMatchIds,
hideAnalysisMatchIds,
subscribeAnalysisHiddenAlerts,
type AnalysisAlertCategory,
} from "@/lib/analysis-alert-hide-cookies";
function CloseIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden
className={className ?? "h-3.5 w-3.5"}
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
);
}
const STYLES: Record<
AnalysisAlertCategory,
{
box: string;
body: string;
chip: string;
dismiss: string;
headerDismiss: string;
}
> = {
mirror: {
box: "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",
body: "text-red-800 dark:text-red-200/90",
chip: "inline-flex items-center gap-0.5 overflow-hidden rounded-md border border-red-400/80 bg-white shadow-sm dark:border-red-700 dark:bg-red-950/80",
dismiss:
"shrink-0 p-1 text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-red-900/80",
headerDismiss:
"shrink-0 rounded-md p-1.5 text-red-800 hover:bg-red-100 dark:text-red-100 dark:hover:bg-red-900/80",
},
notEnded: {
box: "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",
body: "text-amber-900/90 dark:text-amber-200/90",
chip: "inline-flex items-center gap-0.5 overflow-hidden rounded-md border border-amber-400/80 bg-white shadow-sm dark:border-amber-700 dark:bg-amber-950/80",
dismiss:
"shrink-0 p-1 text-amber-800 hover:bg-amber-100 dark:text-amber-100 dark:hover:bg-amber-900/80",
headerDismiss:
"shrink-0 rounded-md p-1.5 text-amber-900 hover:bg-amber-100 dark:text-amber-50 dark:hover:bg-amber-900/80",
},
};
function emptyHiddenFor(category: AnalysisAlertCategory): number[] {
return category === "mirror" ? EMPTY_HIDDEN_MIRROR : EMPTY_HIDDEN_NOT_ENDED;
}
function useHiddenMatchIds(category: AnalysisAlertCategory): number[] {
const [hiddenIds, setHiddenIds] = useState<number[]>(() =>
emptyHiddenFor(category),
);
useEffect(() => {
setHiddenIds(getHiddenAnalysisMatchIds(category));
return subscribeAnalysisHiddenAlerts(() => {
setHiddenIds(getHiddenAnalysisMatchIds(category));
});
}, [category]);
return hiddenIds;
}
type Props = {
category: AnalysisAlertCategory;
matchIds: number[];
title: string;
description: ReactNode;
};
export function AnalysisMatchAlertBox({
category,
matchIds,
title,
description,
}: Props) {
const hiddenIds = useHiddenMatchIds(category);
const hiddenSet = useMemo(() => new Set(hiddenIds), [hiddenIds]);
const visibleIds = useMemo(
() => matchIds.filter((id) => !hiddenSet.has(id)),
[matchIds, hiddenSet],
);
const hideOne = useCallback(
(e: MouseEvent<HTMLButtonElement>, matchId: number) => {
e.preventDefault();
e.stopPropagation();
hideAnalysisMatchIds(category, [matchId]);
},
[category],
);
const hideAll = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
hideAnalysisMatchIds(category, visibleIds);
},
[category, visibleIds],
);
if (matchIds.length === 0 || visibleIds.length === 0) return null;
const s = STYLES[category];
return (
<div className={s.box} role="alert">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium">
{title} ({visibleIds.length.toLocaleString("en-US")}{" "}
{visibleIds.length === 1 ? "match" : "matches"})
</p>
<p className={`mt-1 ${s.body}`}>{description}</p>
</div>
<button
type="button"
onClick={hideAll}
className={s.headerDismiss}
aria-label="Hide all matches in this list"
title="Hide all"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
<ul className="mt-3 flex flex-wrap gap-2">
{visibleIds.map((matchId) => (
<li key={matchId}>
<span className={s.chip}>
<Link
href={`/match-logs/${matchId}`}
className={`px-2.5 py-1 font-mono text-xs font-medium ${
category === "mirror"
? "text-red-900 dark:text-red-50"
: "text-amber-950 dark:text-amber-50"
}`}
>
Match {matchId}
</Link>
<button
type="button"
onClick={(e) => hideOne(e, matchId)}
className={s.dismiss}
aria-label={`Hide match ${matchId}`}
title="Hide"
>
<CloseIcon />
</button>
</span>
</li>
))}
</ul>
</div>
);
}