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