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:
Knacky
2026-05-15 13:16:32 +02:00
parent a26034e1ca
commit d679ff34d8

View File

@@ -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