fix(m7): make executed_at override editable in non-UTC timezones

The naive `new Date(executedAt).toISOString().slice(0, 16)` round-trip on
every keystroke silently shifted the hour by the local TZ offset (Europe
input field is local-time but we kept reformatting via UTC), so the user
could only edit the date — the time component snapped back to UTC every
render.

Fix: keep the local state in `YYYY-MM-DDTHH:MM` form (`executedAtLocal`)
and only convert to/from a UTC ISO at the boundaries — initial sync from
server and submit. Two small helpers `isoToLocalInputValue` /
`localInputValueToIso` carry the conversion explicitly.

Also tightened the useEffect on both Red and Blue zones to depend on
`test.id` instead of the whole `test` object, so polling refetches no
longer wipe an in-progress edit (the 15 s activity poll returns a fresh
object reference even when the row's contents are unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-14 17:05:48 +02:00
parent ed70458d8f
commit 5974a181fd

View File

@@ -74,6 +74,30 @@ function formatRelative(iso: string | null | undefined): string {
return `${days}d ago`;
}
/**
* 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.
*/
function isoToLocalInputValue(iso: string | null | undefined): string {
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())}`;
}
/** 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 {
if (!local) return null;
const d = new Date(local);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
function useMissionTest(missionId: string, testId: string) {
return useQuery({
queryKey: missionTestKeys.detail(missionId, testId),
@@ -155,25 +179,35 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
const [output, setOutput] = useState(test.red_output ?? '');
const [comment, setComment] = useState(test.red_comment_md ?? '');
const [override, setOverride] = useState(test.executed_at_overridden);
const [executedAt, setExecutedAt] = useState(test.executed_at ?? '');
// `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),
);
const dirty =
command !== (test.red_command ?? '') ||
output !== (test.red_output ?? '') ||
comment !== (test.red_comment_md ?? '') ||
override !== test.executed_at_overridden ||
(override && executedAt !== (test.executed_at ?? ''));
(override &&
executedAtLocal !== isoToLocalInputValue(test.executed_at));
// Sync local state when a refetch lands a newer version of the test
// (concurrent collaboration: blue's edit shouldn't blow away our local
// unsaved changes, but a fresh load should).
// 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);
setExecutedAt(test.executed_at ?? '');
// We deliberately reset on every test reload — see comment above.
}, [test]);
setExecutedAtLocal(isoToLocalInputValue(test.executed_at));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [test.id]);
const save = useMutation({
mutationFn: (body: UpdateMissionTestPayload) =>
@@ -197,10 +231,7 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
body.executed_at_overridden = override;
}
if (override) {
const iso = executedAt
? new Date(executedAt).toISOString()
: null;
body.executed_at = iso;
body.executed_at = localInputValueToIso(executedAtLocal);
}
save.mutate(body);
}
@@ -257,10 +288,8 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
{override && (
<input
type="datetime-local"
value={
executedAt ? new Date(executedAt).toISOString().slice(0, 16) : ''
}
onChange={(e) => setExecutedAt(e.target.value)}
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-2xs text-text"
data-testid="red-executed-at"
@@ -309,10 +338,13 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
comment !== (test.blue_comment_md ?? '') ||
levelId !== (test.detection_level_id ?? '');
// Sync from server only on test-identity change (route nav), not on every
// polling refetch — see RedZone for the rationale.
useEffect(() => {
setComment(test.blue_comment_md ?? '');
setLevelId(test.detection_level_id ?? '');
}, [test]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [test.id]);
const save = useMutation({
mutationFn: (body: UpdateMissionTestPayload) =>