Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
483 changes: 483 additions & 0 deletions docs/agentic/phase3-baseline-2026-08-20.md

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions web/src/app/fights/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ import {
type TargetHealingRow,
type TargetBuffRemovalRow,
type FightEventsSummaryRow,
type FightPlayerTimeline,
type FightReadoutOut,
type FightTimeline,
type SquadRollupRow,
} from "@/lib/api";
Expand All @@ -85,7 +83,6 @@ import { SkillUsageTable } from "@/components/SkillUsageTable";
import { PlayerSkillUsageTable } from "@/components/PlayerSkillUsageTable";
import { PlayerSkillUsageFilter } from "@/components/PlayerSkillUsageFilter";
import { LazyTabbedTimelineSection } from "@/components/LazyTabbedTimelineSection";
import { PerFightTimelineSection } from "@/components/PerFightTimelineSection";
import { ReplayPlayer } from "@/components/ReplayPlayer";
// v0.10.26-pre plan 169 commit #1: per-section error indicator.
//
Expand All @@ -107,8 +104,6 @@ import {
FAILED_TO_LOAD_PLAYER_LIST,
FAILED_TO_LOAD_PER_PLAYER_SKILLS,
FAILED_TO_LOAD_FIGHT_DETAILS,
COMBAT_READOUT_FETCH_FAILED,
COMBAT_READOUT_LOADING,
PER_PLAYER_PROMPT_PLACEHOLDER,
NO_EVENT_DATA_TITLE,
NO_EVENT_DATA_BODY,
Expand Down
129 changes: 50 additions & 79 deletions web/src/app/fights/compare/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,6 @@ function FightCompareInner() {
const fightAId = searchParams.get("a") ?? null;
const fightBId = searchParams.get("b") ?? null;

const [readoutA, setReadoutA] = useState<FightReadoutOut | null>(null);
const [readoutB, setReadoutB] = useState<FightReadoutOut | null>(null);
const [loadingA, setLoadingA] = useState(false);
const [loadingB, setLoadingB] = useState(false);
const [errorA, setErrorA] = useState<string | null>(null);
const [errorB, setErrorB] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
(async () => {
Expand All @@ -111,52 +104,6 @@ function FightCompareInner() {
return () => { cancelled = true; };
}, []);

useEffect(() => {
if (!fightAId) return;
let cancelled = false;
setReadoutA(null);
setLoadingA(true);
setErrorA(null);
(async () => {
try {
const data = await fetchFightReadout(fightAId);
if (!cancelled) {
setReadoutA(data);
setLoadingA(false);
}
} catch (err) {
if (!cancelled) {
setErrorA(err instanceof Error ? err.message : String(err));
setLoadingA(false);
}
}
})();
return () => { cancelled = true; };
}, [fightAId]);

useEffect(() => {
if (!fightBId) return;
let cancelled = false;
setReadoutB(null);
setLoadingB(true);
setErrorB(null);
(async () => {
try {
const data = await fetchFightReadout(fightBId);
if (!cancelled) {
setReadoutB(data);
setLoadingB(false);
}
} catch (err) {
if (!cancelled) {
setErrorB(err instanceof Error ? err.message : String(err));
setLoadingB(false);
}
}
})();
return () => { cancelled = true; };
}, [fightBId]);

const updateFight = useCallback(
(slot: "a" | "b", fightId: string) => {
const params = new URLSearchParams(searchParams.toString());
Expand Down Expand Up @@ -279,42 +226,28 @@ function FightCompareInner() {
}}
>
<div style={FIGHT_CARD}>
{loadingA && <p style={{ opacity: 0.7, fontSize: 13 }}>Loading Fight A…</p>}
{errorA && (
<p style={{ color: "var(--accent)", fontSize: 13 }}>
Error: {errorA}
</p>
)}
{!fightAId && (
<div style={EMPTY_STATE}>Select a fight for this slot.</div>
)}
{readoutA && (
<FightColumn
{fightAId ? (
<FightReadoutColumn
key={fightAId}
label="Fight A"
fightId={fightAId!}
readout={readoutA}
fightId={fightAId}
fightInfo={fightAInfo}
/>
) : (
<div style={EMPTY_STATE}>Select a fight for this slot.</div>
)}
</div>

<div style={FIGHT_CARD}>
{loadingB && <p style={{ opacity: 0.7, fontSize: 13 }}>Loading Fight B…</p>}
{errorB && (
<p style={{ color: "var(--accent)", fontSize: 13 }}>
Error: {errorB}
</p>
)}
{!fightBId && (
<div style={EMPTY_STATE}>Select a fight for this slot.</div>
)}
{readoutB && (
<FightColumn
{fightBId ? (
<FightReadoutColumn
key={fightBId}
label="Fight B"
fightId={fightBId!}
readout={readoutB}
fightId={fightBId}
fightInfo={fightBInfo}
/>
) : (
<div style={EMPTY_STATE}>Select a fight for this slot.</div>
)}
</div>
</div>
Expand All @@ -330,6 +263,44 @@ function FightCompareInner() {
);
}

/** Loads one comparison column. The key resets its initial loading state per fight. */
function FightReadoutColumn({
label,
fightId,
fightInfo,
}: {
label: string;
fightId: string;
fightInfo?: FightRow;
}) {
const [readout, setReadout] = useState<FightReadoutOut | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
void fetchFightReadout(fightId)
.then((data) => {
if (!cancelled) setReadout(data);
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
});
return () => {
cancelled = true;
};
}, [fightId]);

if (error) {
return <p style={{ color: "var(--accent)", fontSize: 13 }}>Error: {error}</p>;
}
if (!readout) {
return <p style={{ opacity: 0.7, fontSize: 13 }}>Loading {label}…</p>;
}
return <FightColumn label={label} fightId={fightId} readout={readout} fightInfo={fightInfo} />;
}

/** One column of the side-by-side comparison. */
function FightColumn({
label,
Expand Down
2 changes: 0 additions & 2 deletions web/src/components/CreateWebhookPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,6 @@ import {

import styles from "./CreateWebhookPanel.module.css";

type Phase = "closed" | "form" | "reveal";

type State =
| { phase: "closed" }
| {
Expand Down
2 changes: 0 additions & 2 deletions web/src/components/FightSummaryCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,11 @@ function ProfessionOrEliteCell({
function SummaryPlayerRow({
player,
value,
valueFormatter,
rank,
onRoleFilter,
}: {
player: PlayerReadoutOut;
value: string;
valueFormatter?: (v: number) => string;
rank: 0 | 1 | 2;
onRoleFilter?: (role: string) => void;
}) {
Expand Down
39 changes: 27 additions & 12 deletions web/src/components/LazyTabbedTimelineSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ import { PerFightTimelineSection } from "@/components/PerFightTimelineSection";
import { SectionErrorChip } from "@/components/SectionErrorChip";

type State =
| { status: "loading" }
| { status: "success"; data: FightPlayerTimeline }
| { status: "error"; error: string };
| { status: "loading"; fightId: string; windowS: number; generation: number }
| { status: "success"; fightId: string; windowS: number; generation: number; data: FightPlayerTimeline }
| { status: "error"; fightId: string; windowS: number; generation: number; error: string };

// Skeleton styles mirror the existing CAPTION_STYLE palette so
// the loading state blends with the rest of the page. The fixed
Expand Down Expand Up @@ -100,32 +100,47 @@ export function LazyTabbedTimelineSection({
fightId: string;
windowS: number;
}) {
const [state, setState] = useState<State>({ status: "loading" });
const [request, setRequest] = useState({ fightId, windowS, generation: 0 });
if (request.fightId !== fightId || request.windowS !== windowS) {
setRequest({
fightId,
windowS,
generation: request.generation + 1,
});
}
const generation = request.generation;
const [state, setState] = useState<State>({ status: "loading", fightId, windowS, generation });

useEffect(() => {
// The `active` flag prevents the cleanup-then-resolve race
// (a fast unmount during the in-flight fetch would otherwise
// call setState on an unmounted component, which React
// warns about).
let active = true;
setState({ status: "loading" });
fetchFightPlayerTimeline(fightId, { windowS })
.then((data) => {
if (active) {
setState({ status: "success", data });
setState({ status: "success", fightId, windowS, generation, data });
}
})
.catch((err: unknown) => {
if (active) {
const message =
err instanceof Error ? err.message : "Failed to load player timeline";
setState({ status: "error", error: message });
setState({ status: "error", fightId, windowS, generation, error: message });
}
});
return () => {
active = false;
};
}, [fightId, windowS]);
}, [fightId, windowS, generation]);

// Keep PerFightTimelineSection mounted so its selected tab survives a
// window change. A response tagged for another request is never displayed.
const currentState =
state.fightId === fightId && state.windowS === windowS && state.generation === generation
? state
: ({ status: "loading", fightId, windowS, generation } as const);

// The PerFightTimelineSection is ALWAYS rendered (no display:none
// wrapper). During loading we pass playerTimeline=null which lets
Expand All @@ -144,7 +159,7 @@ export function LazyTabbedTimelineSection({
// per-player view failed to load.
return (
<>
{state.status === "loading" ? (
{currentState.status === "loading" ? (
<div
style={SKELETON_STYLE}
data-testid="timeline-skeleton"
Expand All @@ -153,15 +168,15 @@ export function LazyTabbedTimelineSection({
Loading player timeline…
</div>
) : null}
{state.status === "error" ? (
{currentState.status === "error" ? (
<SectionErrorChip
testid="player-timeline-section-error"
message={state.error}
message={currentState.error}
/>
) : null}
<PerFightTimelineSection
timeline={timeline}
playerTimeline={state.status === "success" ? state.data : null}
playerTimeline={currentState.status === "success" ? currentState.data : null}
/>
</>
);
Expand Down
43 changes: 24 additions & 19 deletions web/src/components/PlayerPositionGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* ``dist_to_com`` (average distance to the squad's center of mass).
*/

import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";

import { fetchFightPositions, type PlayerPositionOut } from "@/lib/api";

Expand All @@ -28,37 +28,42 @@ interface PlayerPositionGridProps {
}

export function PlayerPositionGrid({ fightId }: PlayerPositionGridProps) {
return <PlayerPositionGridContent key={fightId} fightId={fightId} />;
}

function PlayerPositionGridContent({ fightId }: PlayerPositionGridProps) {
const [rows, setRows] = useState<PlayerPositionRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const loadPositions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchFightPositions(fightId);
setRows(
data.players.map((p: PlayerPositionOut) => ({
useEffect(() => {
let active = true;
void fetchFightPositions(fightId)
.then((data) => {
if (!active) return;
setRows(
data.players.map((p: PlayerPositionOut) => ({
account_name: p.account_name,
name: p.name,
profession: p.profession,
elite_spec: p.elite_spec,
stack_dist: p.stack_dist ?? null,
dist_to_com: p.dist_to_com ?? null,
sample_count: p.samples?.length ?? 0,
})),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load positions");
} finally {
setLoading(false);
}
})),
);
setLoading(false);
})
.catch((err: unknown) => {
if (!active) return;
setError(err instanceof Error ? err.message : "Failed to load positions");
setLoading(false);
});
return () => {
active = false;
};
}, [fightId]);

useEffect(() => {
loadPositions();
}, [loadPositions]);

if (loading) {
return <p className="text-muted-foreground p-4">Loading positions…</p>;
}
Expand Down
Loading
Loading