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
|
||||
* `<input type="datetime-local">` 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 && <Alert accent="red">{apiErr.message}</Alert>}
|
||||
|
||||
{/* 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">
|
||||
<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
|
||||
</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>
|
||||
{override && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<input
|
||||
type="datetime-local"
|
||||
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-xs text-text"
|
||||
value={executedAtInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<span className="font-mono text-3xs text-text-dim">
|
||||
Browser local time — server stores the UTC equivalent.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
|
||||
Reference in New Issue
Block a user