analysis complete
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user