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`;
|
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) {
|
function useMissionTest(missionId: string, testId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: missionTestKeys.detail(missionId, testId),
|
queryKey: missionTestKeys.detail(missionId, testId),
|
||||||
@@ -155,25 +179,35 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
|
|||||||
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);
|
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 =
|
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 ||
|
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
|
// Sync local state only when the *identity* of the test changes (route
|
||||||
// (concurrent collaboration: blue's edit shouldn't blow away our local
|
// change). Polling refetches return a new object reference for the same
|
||||||
// unsaved changes, but a fresh load should).
|
// 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);
|
setOverride(test.executed_at_overridden);
|
||||||
setExecutedAt(test.executed_at ?? '');
|
setExecutedAtLocal(isoToLocalInputValue(test.executed_at));
|
||||||
// We deliberately reset on every test reload — see comment above.
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [test]);
|
}, [test.id]);
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: (body: UpdateMissionTestPayload) =>
|
mutationFn: (body: UpdateMissionTestPayload) =>
|
||||||
@@ -197,10 +231,7 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
|
|||||||
body.executed_at_overridden = override;
|
body.executed_at_overridden = override;
|
||||||
}
|
}
|
||||||
if (override) {
|
if (override) {
|
||||||
const iso = executedAt
|
body.executed_at = localInputValueToIso(executedAtLocal);
|
||||||
? new Date(executedAt).toISOString()
|
|
||||||
: null;
|
|
||||||
body.executed_at = iso;
|
|
||||||
}
|
}
|
||||||
save.mutate(body);
|
save.mutate(body);
|
||||||
}
|
}
|
||||||
@@ -257,10 +288,8 @@ function RedZone({ test, missionId, canWriteRed }: RedZoneProps) {
|
|||||||
{override && (
|
{override && (
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={
|
value={executedAtLocal}
|
||||||
executedAt ? new Date(executedAt).toISOString().slice(0, 16) : ''
|
onChange={(e) => setExecutedAtLocal(e.target.value)}
|
||||||
}
|
|
||||||
onChange={(e) => setExecutedAt(e.target.value)}
|
|
||||||
disabled={!canOverride}
|
disabled={!canOverride}
|
||||||
className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-2xs text-text"
|
className="rounded-md border border-border bg-bg-card px-2 py-1 font-mono text-2xs text-text"
|
||||||
data-testid="red-executed-at"
|
data-testid="red-executed-at"
|
||||||
@@ -309,10 +338,13 @@ function BlueZone({ test, missionId, canWriteBlue, detectionLevels }: BlueZonePr
|
|||||||
comment !== (test.blue_comment_md ?? '') ||
|
comment !== (test.blue_comment_md ?? '') ||
|
||||||
levelId !== (test.detection_level_id ?? '');
|
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(() => {
|
useEffect(() => {
|
||||||
setComment(test.blue_comment_md ?? '');
|
setComment(test.blue_comment_md ?? '');
|
||||||
setLevelId(test.detection_level_id ?? '');
|
setLevelId(test.detection_level_id ?? '');
|
||||||
}, [test]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [test.id]);
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: (body: UpdateMissionTestPayload) =>
|
mutationFn: (body: UpdateMissionTestPayload) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user