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>
|
||||
);
|
||||
}
|
||||
@@ -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