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:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user