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) =>