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