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 Link from "next/link";
import { useMemo } from "react"; import { useMemo } from "react";
import { ClickableUserId } from "@/components/clickable-user-id"; import { ClickableUserId } from "@/components/clickable-user-id";
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart"; import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart"; import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url"; import { buildDashboardHref } from "@/lib/dashboard-search-url";
@@ -269,41 +270,19 @@ export function AdminMatchAnalysis({
</span> </span>
</div> </div>
{analysis.mirrorExceptionDisconnectMatchIds.length > 0 ? ( <AnalysisMatchAlertBox
<div category="mirror"
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" matchIds={analysis.mirrorExceptionDisconnectMatchIds}
role="alert" title="Mirror exception disconnect"
> description={
<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{" "} These matches include a{" "}
<span className="font-mono">[Mirror/Error]</span> line where a <span className="font-mono">[Mirror/Error]</span> line where a
player was disconnected because handling a command caused an player was disconnected because handling a command caused an
exception. 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"> <div className="grid gap-4 lg:grid-cols-3">
<StatBlock <StatBlock
@@ -402,32 +381,12 @@ export function AdminMatchAnalysis({
</div> </div>
{analysis.notEndedMatchIds.length > 0 ? ( {analysis.notEndedMatchIds.length > 0 ? (
<div <AnalysisMatchAlertBox
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" category="notEnded"
role="status" matchIds={analysis.notEndedMatchIds}
> title="Not ended"
<p className="font-medium"> description="Log file present but no winner PATCH line — the match did not finish normally."
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"> <p className="text-sm text-zinc-600 dark:text-zinc-400">
Every match with a log file includes a winner PATCH line. 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>
);
}
+129
View File
@@ -0,0 +1,129 @@
/** Client-side cookie storing per-admin dismissed analysis alert match ids. */
export const ANALYSIS_HIDDEN_ALERTS_COOKIE = "kk_analysis_hidden_alerts";
export type AnalysisAlertCategory = "mirror" | "notEnded";
type StoredHiddenAlerts = {
mirror: number[];
notEnded: number[];
};
const EMPTY_MIRROR: number[] = [];
const EMPTY_NOT_ENDED: number[] = [];
const MAX_IDS_PER_CATEGORY = 400;
const COOKIE_MAX_AGE_SEC = 60 * 60 * 24 * 365;
export const ANALYSIS_HIDDEN_ALERTS_EVENT = "kk-analysis-hidden-alerts-change";
/** Stable empty snapshots for useSyncExternalStore / initial state. */
export const EMPTY_HIDDEN_MIRROR = EMPTY_MIRROR;
export const EMPTY_HIDDEN_NOT_ENDED = EMPTY_NOT_ENDED;
let storeCache: {
cookieValue: string;
mirror: number[];
notEnded: number[];
} | null = null;
function notifyHiddenAlertsChanged(): void {
if (typeof window !== "undefined") {
window.dispatchEvent(new Event(ANALYSIS_HIDDEN_ALERTS_EVENT));
}
}
export function subscribeAnalysisHiddenAlerts(
onStoreChange: () => void,
): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener(ANALYSIS_HIDDEN_ALERTS_EVENT, onStoreChange);
return () =>
window.removeEventListener(ANALYSIS_HIDDEN_ALERTS_EVENT, onStoreChange);
}
function parseIdList(raw: unknown): number[] {
if (!Array.isArray(raw)) return [];
const out: number[] = [];
for (const v of raw) {
const n = Number(v);
if (Number.isInteger(n) && n >= 1) out.push(n);
}
return [...new Set(out)].sort((a, b) => a - b);
}
function invalidateStoreCache(): void {
storeCache = null;
}
function readStored(): StoredHiddenAlerts {
if (typeof document === "undefined") {
return { mirror: EMPTY_MIRROR, notEnded: EMPTY_NOT_ENDED };
}
const match = document.cookie.match(
new RegExp(`(?:^|; )${ANALYSIS_HIDDEN_ALERTS_COOKIE}=([^;]*)`),
);
const cookieValue = match?.[1] ?? "";
if (storeCache?.cookieValue === cookieValue) {
return storeCache;
}
let mirror = EMPTY_MIRROR;
let notEnded = EMPTY_NOT_ENDED;
if (cookieValue) {
try {
const parsed = JSON.parse(decodeURIComponent(cookieValue)) as unknown;
if (parsed && typeof parsed === "object") {
const o = parsed as Record<string, unknown>;
const m = parseIdList(o.mirror);
const n = parseIdList(o.notEnded);
if (m.length > 0) mirror = m;
if (n.length > 0) notEnded = n;
}
} catch {
// ignore corrupt cookie
}
}
storeCache = { cookieValue, mirror, notEnded };
return storeCache;
}
function writeStored(data: StoredHiddenAlerts): void {
if (typeof document === "undefined") return;
const value = encodeURIComponent(JSON.stringify(data));
document.cookie = `${ANALYSIS_HIDDEN_ALERTS_COOKIE}=${value}; path=/; max-age=${COOKIE_MAX_AGE_SEC}; SameSite=Lax`;
invalidateStoreCache();
}
function trimIds(ids: number[]): number[] {
if (ids.length <= MAX_IDS_PER_CATEGORY) return ids;
return ids.slice(ids.length - MAX_IDS_PER_CATEGORY);
}
/** Match ids the admin dismissed for this alert category (stable array reference). */
export function getHiddenAnalysisMatchIds(
category: AnalysisAlertCategory,
): number[] {
const stored = readStored();
return category === "mirror" ? stored.mirror : stored.notEnded;
}
/** Add match ids to the hide list for a category (persists in cookie). */
export function hideAnalysisMatchIds(
category: AnalysisAlertCategory,
matchIds: number[],
): void {
const stored = readStored();
const set = new Set(stored[category]);
for (const id of matchIds) {
if (Number.isInteger(id) && id >= 1) set.add(id);
}
const next = trimIds([...set].sort((a, b) => a - b));
writeStored({
mirror: category === "mirror" ? next : stored.mirror,
notEnded: category === "notEnded" ? next : stored.notEnded,
});
notifyHiddenAlertsChanged();
}