diff --git a/frontend/src/pages/MissionTestPage.tsx b/frontend/src/pages/MissionTestPage.tsx index 8803de3..d07b258 100644 --- a/frontend/src/pages/MissionTestPage.tsx +++ b/frontend/src/pages/MissionTestPage.tsx @@ -77,27 +77,22 @@ function formatRelative(iso: string | null | undefined): string { } /** - * 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. + * Datetime helpers — zero timezone interpretation. The user types a + * wall-clock value, the server stores it verbatim, and we display it back + * unchanged. We never go through `new Date(...).toISOString()` because that + * would shift the value by the browser's local offset on every render. */ -function isoToLocalInputValue(iso: string | null | undefined): string { +function isoToInputValue(iso: string | null | undefined): string { + // `2026-05-15T10:30:00+00:00` → `2026-05-15T10:30`. 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())}`; + return iso.slice(0, 16); } -/** 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 { +function inputValueToIso(local: string): string | null { + // `2026-05-15T10:30` → `2026-05-15T10:30:00Z`. The `Z` makes the ISO + // unambiguous for the backend without applying any local-time shift. if (!local) return null; - const d = new Date(local); - if (Number.isNaN(d.getTime())) return null; - return d.toISOString(); + return `${local}:00Z`; } function useMissionTest(missionId: string, testId: string) { @@ -180,34 +175,27 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) { const [command, setCommand] = useState(test.red_command ?? ''); const [output, setOutput] = useState(test.red_output ?? ''); const [comment, setComment] = useState(test.red_comment_md ?? ''); - const [override, setOverride] = useState(test.executed_at_overridden); - // `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), + // `executedAtInput` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local + // input speaks — stored verbatim, displayed verbatim, no TZ shift on either + // side. Conversion only happens at submit (append `:00Z`). + const [executedAtInput, setExecutedAtInput] = useState( + isoToInputValue(test.executed_at), ); + const executedAtServer = isoToInputValue(test.executed_at); const dirty = command !== (test.red_command ?? '') || output !== (test.red_output ?? '') || comment !== (test.red_comment_md ?? '') || - override !== test.executed_at_overridden || - (override && - executedAtLocal !== isoToLocalInputValue(test.executed_at)); + executedAtInput !== executedAtServer; // 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); - setExecutedAtLocal(isoToLocalInputValue(test.executed_at)); + setExecutedAtInput(isoToInputValue(test.executed_at)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [test.id]); @@ -229,17 +217,18 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) { red_output: output.length === 0 ? null : output, red_comment_md: comment.trim() || null, }; - if (override !== test.executed_at_overridden) { - body.executed_at_overridden = override; - } - if (override) { - body.executed_at = localInputValueToIso(executedAtLocal); + // Any change to the executed_at field is implicitly a manual override — + // the operator wouldn't have touched it otherwise. The override flag is + // a backend-side bookkeeping detail and never surfaced in the UI. + if (executedAtInput !== executedAtServer) { + body.executed_at = inputValueToIso(executedAtInput); + body.executed_at_overridden = executedAtInput !== ''; } save.mutate(body); } const apiErr = save.error instanceof ApiError ? save.error : null; - const canOverride = + const canEditExecutedAt = canWriteRed && (test.state === 'executed' || test.state === 'reviewed_by_blue'); return ( @@ -248,58 +237,20 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) { {apiErr && {apiErr.message}} {/* The execution timestamp anchors the blue team's log correlation, - so it leads the form (cf. user feedback 2026-05-15). */} + so it leads the form (cf. user feedback 2026-05-15). What the user + types is what gets stored — no timezone interpretation. */}
- + -

- {test.executed_at ? ( - <> - {test.executed_at} - {test.executed_at_overridden && ( - · overridden - )} - - ) : ( - - Not yet executed — use the "→ Executed" transition above to stamp. - - )} -

- - {override && ( -
- setExecutedAtLocal(e.target.value)} - disabled={!canOverride} - className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-xs text-text" - data-testid="red-executed-at" - /> - - Browser local time — server stores the UTC equivalent. - -
- )} + setExecutedAtInput(e.target.value)} + disabled={!canEditExecutedAt} + className="mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright" + data-testid="red-executed-at" + />