fix(m7): drop override UI + verbatim executed_at, no timezone shift
User feedback: only the date+time matters. No override toggle, no
"overridden" badge, no UTC/local-time conversion. What you type is what
gets stored is what you see.
- Removed the `override` state, the checkbox label, the conditional show
of the input, and the "auto-stamped at" hint.
- Single always-on datetime-local input under the "Executed at" label,
disabled only while the test is `pending` (backend rejects timestamp
writes until the state machine reaches executed/reviewed_by_blue).
- `isoToInputValue` and `inputValueToIso` now strip/append the time
segment verbatim — `iso.slice(0, 16)` and `${local}:00Z`. No more
round-trip through `new Date(...).toISOString()` that pulled values
through the browser's local TZ.
- Any edit of the input is implicitly an override at submit time
(`executed_at_overridden = true` if non-empty). The flag is purely
internal bookkeeping — never surfaced in the UI per user request.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
* Datetime helpers — zero timezone interpretation. The user types a
|
||||||
* `<input type="datetime-local">` expects (and displays) in the operator's
|
* wall-clock value, the server stores it verbatim, and we display it back
|
||||||
* timezone. The naive `new Date(iso).toISOString().slice(0, 16)` shortcut
|
* unchanged. We never go through `new Date(...).toISOString()` because that
|
||||||
* round-trips through UTC and silently shifts the time by the local offset
|
* would shift the value by the browser's local offset on every render.
|
||||||
* every time the user types, making the hour un-editable in any non-UTC TZ.
|
|
||||||
*/
|
*/
|
||||||
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 '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
return iso.slice(0, 16);
|
||||||
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)
|
function inputValueToIso(local: string): string | null {
|
||||||
* back into a UTC ISO string. Returns null if the input is empty/invalid. */
|
// `2026-05-15T10:30` → `2026-05-15T10:30:00Z`. The `Z` makes the ISO
|
||||||
function localInputValueToIso(local: string): string | null {
|
// unambiguous for the backend without applying any local-time shift.
|
||||||
if (!local) return null;
|
if (!local) return null;
|
||||||
const d = new Date(local);
|
return `${local}:00Z`;
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
|
||||||
return d.toISOString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useMissionTest(missionId: string, testId: string) {
|
function useMissionTest(missionId: string, testId: string) {
|
||||||
@@ -180,34 +175,27 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
|
|||||||
const [command, setCommand] = useState(test.red_command ?? '');
|
const [command, setCommand] = useState(test.red_command ?? '');
|
||||||
const [output, setOutput] = useState(test.red_output ?? '');
|
const [output, setOutput] = useState(test.red_output ?? '');
|
||||||
const [comment, setComment] = useState(test.red_comment_md ?? '');
|
const [comment, setComment] = useState(test.red_comment_md ?? '');
|
||||||
const [override, setOverride] = useState(test.executed_at_overridden);
|
// `executedAtInput` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local
|
||||||
// `executedAtLocal` is the raw `YYYY-MM-DDTHH:MM` string the datetime-local
|
// input speaks — stored verbatim, displayed verbatim, no TZ shift on either
|
||||||
// input speaks; we only convert to/from UTC ISO at the boundaries
|
// side. Conversion only happens at submit (append `:00Z`).
|
||||||
// (initial sync + submit). Doing the round-trip on every keystroke shifted
|
const [executedAtInput, setExecutedAtInput] = useState(
|
||||||
// the hour by the local TZ offset and made the time field uneditable in
|
isoToInputValue(test.executed_at),
|
||||||
// any non-UTC zone (cf. tasks/lessons.md M7 fix).
|
|
||||||
const [executedAtLocal, setExecutedAtLocal] = useState(
|
|
||||||
isoToLocalInputValue(test.executed_at),
|
|
||||||
);
|
);
|
||||||
|
const executedAtServer = isoToInputValue(test.executed_at);
|
||||||
const dirty =
|
const dirty =
|
||||||
command !== (test.red_command ?? '') ||
|
command !== (test.red_command ?? '') ||
|
||||||
output !== (test.red_output ?? '') ||
|
output !== (test.red_output ?? '') ||
|
||||||
comment !== (test.red_comment_md ?? '') ||
|
comment !== (test.red_comment_md ?? '') ||
|
||||||
override !== test.executed_at_overridden ||
|
executedAtInput !== executedAtServer;
|
||||||
(override &&
|
|
||||||
executedAtLocal !== isoToLocalInputValue(test.executed_at));
|
|
||||||
|
|
||||||
// Sync local state only when the *identity* of the test changes (route
|
// Sync local state only when the *identity* of the test changes (route
|
||||||
// change). Polling refetches return a new object reference for the same
|
// change). Polling refetches return a new object reference for the same
|
||||||
// test_id; resetting on those would wipe whatever the user is mid-typing.
|
// 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(() => {
|
useEffect(() => {
|
||||||
setCommand(test.red_command ?? '');
|
setCommand(test.red_command ?? '');
|
||||||
setOutput(test.red_output ?? '');
|
setOutput(test.red_output ?? '');
|
||||||
setComment(test.red_comment_md ?? '');
|
setComment(test.red_comment_md ?? '');
|
||||||
setOverride(test.executed_at_overridden);
|
setExecutedAtInput(isoToInputValue(test.executed_at));
|
||||||
setExecutedAtLocal(isoToLocalInputValue(test.executed_at));
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [test.id]);
|
}, [test.id]);
|
||||||
|
|
||||||
@@ -229,17 +217,18 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
|
|||||||
red_output: output.length === 0 ? null : output,
|
red_output: output.length === 0 ? null : output,
|
||||||
red_comment_md: comment.trim() || null,
|
red_comment_md: comment.trim() || null,
|
||||||
};
|
};
|
||||||
if (override !== test.executed_at_overridden) {
|
// Any change to the executed_at field is implicitly a manual override —
|
||||||
body.executed_at_overridden = 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 (override) {
|
if (executedAtInput !== executedAtServer) {
|
||||||
body.executed_at = localInputValueToIso(executedAtLocal);
|
body.executed_at = inputValueToIso(executedAtInput);
|
||||||
|
body.executed_at_overridden = executedAtInput !== '';
|
||||||
}
|
}
|
||||||
save.mutate(body);
|
save.mutate(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiErr = save.error instanceof ApiError ? save.error : null;
|
const apiErr = save.error instanceof ApiError ? save.error : null;
|
||||||
const canOverride =
|
const canEditExecutedAt =
|
||||||
canWriteRed && (test.state === 'executed' || test.state === 'reviewed_by_blue');
|
canWriteRed && (test.state === 'executed' || test.state === 'reviewed_by_blue');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -248,58 +237,20 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
|
|||||||
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
|
{apiErr && <Alert accent="red">{apiErr.message}</Alert>}
|
||||||
|
|
||||||
{/* The execution timestamp anchors the blue team's log correlation,
|
{/* 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. */}
|
||||||
<div data-testid="red-executed-block">
|
<div data-testid="red-executed-block">
|
||||||
<span className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
|
<label className="block font-mono text-3xs font-semibold uppercase tracking-wider2 text-text-dim">
|
||||||
Executed at
|
Executed at
|
||||||
</span>
|
|
||||||
<p
|
|
||||||
className="mt-1 font-mono text-xs text-text-bright"
|
|
||||||
data-testid="red-executed-current"
|
|
||||||
>
|
|
||||||
{test.executed_at ? (
|
|
||||||
<>
|
|
||||||
<code>{test.executed_at}</code>
|
|
||||||
{test.executed_at_overridden && (
|
|
||||||
<span className="ml-2 text-text-dim">· overridden</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-text-dim">
|
|
||||||
Not yet executed — use the "→ Executed" transition above to stamp.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<label className="mt-2 flex items-center gap-2 font-mono text-2xs text-text-dim">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={override}
|
|
||||||
onChange={(e) => setOverride(e.target.checked)}
|
|
||||||
disabled={!canOverride}
|
|
||||||
data-testid="red-executed-override"
|
|
||||||
/>
|
|
||||||
Override timestamp
|
|
||||||
{!canOverride && (
|
|
||||||
<span className="text-text-dim/60">
|
|
||||||
(mark the test executed first)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
{override && (
|
<input
|
||||||
<div className="mt-2 flex flex-col gap-1">
|
type="datetime-local"
|
||||||
<input
|
value={executedAtInput}
|
||||||
type="datetime-local"
|
onChange={(e) => setExecutedAtInput(e.target.value)}
|
||||||
value={executedAtLocal}
|
disabled={!canEditExecutedAt}
|
||||||
onChange={(e) => setExecutedAtLocal(e.target.value)}
|
className="mt-1 w-full rounded-md border border-border bg-bg-card px-3 py-2 font-mono text-xs text-text-bright"
|
||||||
disabled={!canOverride}
|
data-testid="red-executed-at"
|
||||||
className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-xs text-text"
|
/>
|
||||||
data-testid="red-executed-at"
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-3xs text-text-dim">
|
|
||||||
Browser local time — server stores the UTC equivalent.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
Reference in New Issue
Block a user