diff --git a/frontend/src/pages/MissionTestPage.tsx b/frontend/src/pages/MissionTestPage.tsx
index fc6a8ef..3544ae7 100644
--- a/frontend/src/pages/MissionTestPage.tsx
+++ b/frontend/src/pages/MissionTestPage.tsx
@@ -74,6 +74,30 @@ function formatRelative(iso: string | null | undefined): string {
return `${days}d ago`;
}
+/**
+ * Convert a UTC ISO datetime to the wall-clock `YYYY-MM-DDTHH:MM` string that
+ * `` expects (and displays) in the operator's
+ * timezone. The naive `new Date(iso).toISOString().slice(0, 16)` shortcut
+ * round-trips through UTC and silently shifts the time by the local offset
+ * every time the user types, making the hour un-editable in any non-UTC TZ.
+ */
+function isoToLocalInputValue(iso: string | null | undefined): string {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return '';
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+/** Parse a `datetime-local` input value (already in wall-clock local time)
+ * back into a UTC ISO string. Returns null if the input is empty/invalid. */
+function localInputValueToIso(local: string): string | null {
+ if (!local) return null;
+ const d = new Date(local);
+ if (Number.isNaN(d.getTime())) return null;
+ return d.toISOString();
+}
+
function useMissionTest(missionId: string, testId: string) {
return useQuery({
queryKey: missionTestKeys.detail(missionId, testId),
@@ -155,25 +179,35 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
const [output, setOutput] = useState(test.red_output ?? '');
const [comment, setComment] = useState(test.red_comment_md ?? '');
const [override, setOverride] = useState(test.executed_at_overridden);
- const [executedAt, setExecutedAt] = useState(test.executed_at ?? '');
+ // `executedAtLocal` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local
+ // input speaks; we only convert to/from UTC ISO at the boundaries
+ // (initial sync + submit). Doing the round-trip on every keystroke shifted
+ // the hour by the local TZ offset and made the time field uneditable in
+ // any non-UTC zone (cf. tasks/lessons.md M7 fix).
+ const [executedAtLocal, setExecutedAtLocal] = useState(
+ isoToLocalInputValue(test.executed_at),
+ );
const dirty =
command !== (test.red_command ?? '') ||
output !== (test.red_output ?? '') ||
comment !== (test.red_comment_md ?? '') ||
override !== test.executed_at_overridden ||
- (override && executedAt !== (test.executed_at ?? ''));
+ (override &&
+ executedAtLocal !== isoToLocalInputValue(test.executed_at));
- // Sync local state when a refetch lands a newer version of the test
- // (concurrent collaboration: blue's edit shouldn't blow away our local
- // unsaved changes, but a fresh load should).
+ // Sync local state only when the *identity* of the test changes (route
+ // change). Polling refetches return a new object reference for the same
+ // test_id; resetting on those would wipe whatever the user is mid-typing.
+ // Successful saves push the new test through `setQueryData`, which is fine
+ // — the saved fields already match local state.
useEffect(() => {
setCommand(test.red_command ?? '');
setOutput(test.red_output ?? '');
setComment(test.red_comment_md ?? '');
setOverride(test.executed_at_overridden);
- setExecutedAt(test.executed_at ?? '');
- // We deliberately reset on every test reload — see comment above.
- }, [test]);
+ setExecutedAtLocal(isoToLocalInputValue(test.executed_at));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [test.id]);
const save = useMutation({
mutationFn: (body: UpdateMissionTestPayload) =>
@@ -197,10 +231,7 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
body.executed_at_overridden = override;
}
if (override) {
- const iso = executedAt
- ? new Date(executedAt).toISOString()
- : null;
- body.executed_at = iso;
+ body.executed_at = localInputValueToIso(executedAtLocal);
}
save.mutate(body);
}
@@ -257,10 +288,8 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
{override && (
setExecutedAt(e.target.value)}
+ value={executedAtLocal}
+ onChange={(e) => setExecutedAtLocal(e.target.value)}
disabled={!canOverride}
className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-2xs text-text"
data-testid="red-executed-at"
@@ -309,10 +338,13 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
comment !== (test.blue_comment_md ?? '') ||
levelId !== (test.detection_level_id ?? '');
+ // Sync from server only on test-identity change (route nav), not on every
+ // polling refetch — see RedZone for the rationale.
useEffect(() => {
setComment(test.blue_comment_md ?? '');
setLevelId(test.detection_level_id ?? '');
- }, [test]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [test.id]);
const save = useMutation({
mutationFn: (body: UpdateMissionTestPayload) =>