From e816c0974e5bbfa062d62045e1f09ca35c3908f9 Mon Sep 17 00:00:00 2001 From: Abhishek B R Date: Thu, 22 Oct 2026 10:22:09 +0000 Subject: [PATCH] fix(ui): accept hour-only offsets when pasting a date-time (#15495) Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com> --- web/src/utils/dateTimeRangeParse.spec.ts | 20 ++++++++++++++++++++ web/src/utils/dateTimeRangeParse.ts | 17 +++++++++++------ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/web/src/utils/dateTimeRangeParse.spec.ts b/web/src/utils/dateTimeRangeParse.spec.ts index 96f4026811..e8ab6dce65 100644 --- a/web/src/utils/dateTimeRangeParse.spec.ts +++ b/web/src/utils/dateTimeRangeParse.spec.ts @@ -78,6 +78,15 @@ describe("parseDateRangeString", () => { }); }); + it("accepts an hour-only offset (+HH), which date-fns renders for whole-hour zones", () => { + // 08:33:06-05 is the same instant as 13:33:06Z. + expect(parseDateRangeString("2026-07-21T08:33:06-05 - 2026-07-21T10:33:06-05")).toEqual({ + type: "timestamp", + startMicros: 1784640786000000, + endMicros: 1784647986000000, + }); + }); + it("without an offset, resolves to the picker-timezone-dependent absolute shape", () => { expect(parseDateRangeString("2026-07-21T13:33:06 - 2026-07-21T15:33:06")).toEqual({ type: "absolute", @@ -399,6 +408,17 @@ describe("parseSingleDateTime", () => { }); }); + it("with an hour-only offset, as formatToHuman renders it in a whole-hour zone", () => { + expect(parseSingleDateTime("Jul 22, 2026 01:40:28.008 -05")).toEqual({ + type: "timestamp", + micros: 1784702428008000, + }); + expect(parseSingleDateTime("Jul 22, 2026 08:40:28.008 +02")).toEqual({ + type: "timestamp", + micros: 1784702428008000, + }); + }); + it("without milliseconds", () => { expect(parseSingleDateTime("Jul 22, 2026 06:40:28 +0000")).toEqual({ type: "timestamp", diff --git a/web/src/utils/dateTimeRangeParse.ts b/web/src/utils/dateTimeRangeParse.ts index 706655efb8..8f8cfc1941 100644 --- a/web/src/utils/dateTimeRangeParse.ts +++ b/web/src/utils/dateTimeRangeParse.ts @@ -55,10 +55,10 @@ const TIMESTAMP_RANGE_RE = /^(\d{10,16})\s*-\s*(\d{10,16})$/; const ABSOLUTE_SINGLE_RE = /^(\d{4}\/\d{2}\/\d{2})(?:\s+(\d{2}:\d{2}:\d{2}))?$/; const TIMESTAMP_SINGLE_RE = /^(\d{10,16})$/; const ISO_SINGLE_RE = - /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?(Z|[+-]\d{2}:?\d{2})?$/; + /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/; // The app's own log/trace timestamp display format (src/utils/date.ts formatTimestamp). const HUMAN_LOG_SINGLE_RE = - /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),?\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:\s+(Z|[+-]\d{2}:?\d{2}))?$/i; + /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2}),?\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(?:\s+(Z|[+-]\d{2}(?::?\d{2})?))?$/i; const MONTH_INDEX: Record = { jan: "01", feb: "02", @@ -74,6 +74,13 @@ const MONTH_INDEX: Record = { dec: "12", }; +// date-fns `X` (the app's `Z` token) renders whole-hour zones as `+HH`, with no minutes. +function normalizeOffset(offset: string): string { + if (offset.toUpperCase() === "Z") return "Z"; + const [, hours, minutes = "00"] = offset.match(/^([+-]\d{2}):?(\d{2})?$/) ?? []; + return `${hours}:${minutes}`; +} + function isValidDateTimeParts( year: string, month: string, @@ -208,8 +215,7 @@ function parseIsoSingle(raw: string): IsoSingleParsed | null { if (offset) { // Explicit offset already IS the instant — ignores the picker's timezone. - const normalizedOffset = - offset === "Z" ? "Z" : offset.replace(/^([+-]\d{2}):?(\d{2})$/, "$1:$2"); + const normalizedOffset = normalizeOffset(offset); const iso = `${year}-${month}-${day}T${hour ?? "00"}:${minute ?? "00"}:${second ?? "00"}${normalizedOffset}`; const ms = Date.parse(iso); if (Number.isNaN(ms)) return null; @@ -234,8 +240,7 @@ function parseHumanLogSingle(raw: string): IsoSingleParsed | null { if (!isValidDateTimeParts(year, month, dd, hour, minute, secWithMs)) return null; if (offset) { - const normalizedOffset = - offset.toUpperCase() === "Z" ? "Z" : offset.replace(/^([+-]\d{2}):?(\d{2})$/, "$1:$2"); + const normalizedOffset = normalizeOffset(offset); const iso = `${year}-${month}-${dd}T${hour}:${minute}:${secWithMs}${normalizedOffset}`; const parsedMs = Date.parse(iso); if (Number.isNaN(parsedMs)) return null;