You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/09/29 02:58:05 UTC

(superset) branch master updated: fix(timeseries): preserve explicit UTC offsets in normalizeTimestamp (#43960)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 95db7730256 fix(timeseries): preserve explicit UTC offsets in normalizeTimestamp (#43960)
95db7730256 is described below

commit 95db7730256979f04de14f9a7cbcf65db60a7ce8
Author: Konstantin Khlopkov <47...@users.noreply.github.com>
AuthorDate: Tue Sep 29 05:57:58 2026 +0300

    fix(timeseries): preserve explicit UTC offsets in normalizeTimestamp (#43960)
    
    Co-authored-by: Claude Code <no...@anthropic.com>
    Co-authored-by: Evan Rusackas <ev...@preset.io>
---
 .../src/time-format/utils/normalizeTimestamp.ts    | 18 ++++++
 .../time-format/utils/normalizeTimestamp.test.ts   | 70 ++++++++++++++++++++++
 2 files changed, 88 insertions(+)

diff --git a/superset-frontend/packages/superset-ui-core/src/time-format/utils/normalizeTimestamp.ts b/superset-frontend/packages/superset-ui-core/src/time-format/utils/normalizeTimestamp.ts
index f8f2220e79d..51b35ab4a2c 100644
--- a/superset-frontend/packages/superset-ui-core/src/time-format/utils/normalizeTimestamp.ts
+++ b/superset-frontend/packages/superset-ui-core/src/time-format/utils/normalizeTimestamp.ts
@@ -17,6 +17,16 @@
  * under the License.
  */
 
+/**
+ * Timezone-aware strings carry an explicit UTC offset (`Z` or `±hh:mm`).
+ * Rewriting them to a bare `Z` would relabel the wall-clock time as UTC,
+ * shifting the instant; they are returned untouched. Timezone names
+ * (`UTC`, `Europe/Helsinki`) are not valid ISO offsets and are still
+ * stripped the historic way. Trino renders `timestamp with time zone`
+ * with a space before the offset; that separator is accepted too.
+ */
+const TS_REGEX_TZ_AWARE =
+  /^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2}:\d{2}\.?\d*)[\s]?(?:Z|[+-]\d{2}:?\d{2})$/;
 export const TS_REGEX = /(\d{4}-\d{2}-\d{2})[\sT](\d{2}:\d{2}:\d{2}\.?\d*).*/;
 
 // Matches a bare date with no time component, e.g. "2023-03-11".
@@ -29,6 +39,14 @@ export const TS_REGEX = /(\d{4}-\d{2}-\d{2})[\sT](\d{2}:\d{2}:\d{2}\.?\d*).*/;
 export const DATE_ONLY_REGEX = /^(\d{4}-\d{2}-\d{2})$/;
 
 export default function normalizeTimestamp(value: string): string {
+  if (TS_REGEX_TZ_AWARE.test(value)) {
+    // The ECMA-262 Date Time String Format only recognizes an offset with a
+    // colon (`±hh:mm`); a compact offset (`+0330`) is outside that grammar,
+    // so `new Date(...)` on the result is implementation-defined and known
+    // to disagree across browsers. Insert the colon: it doesn't change the
+    // instant the offset represents, only its string form.
+    return value.replace(/([+-]\d{2})(\d{2})$/, '$1:$2');
+  }
   const match = value.match(TS_REGEX);
   if (match) {
     return `${match[1]}T${match[2]}Z`;
diff --git a/superset-frontend/packages/superset-ui-core/test/time-format/utils/normalizeTimestamp.test.ts b/superset-frontend/packages/superset-ui-core/test/time-format/utils/normalizeTimestamp.test.ts
index 7b20c30ec26..a6b022b1e7c 100644
--- a/superset-frontend/packages/superset-ui-core/test/time-format/utils/normalizeTimestamp.test.ts
+++ b/superset-frontend/packages/superset-ui-core/test/time-format/utils/normalizeTimestamp.test.ts
@@ -46,3 +46,73 @@ test('normalizeTimestamp should normalize bare date-only strings to UTC midnight
   expect(normalizeTimestamp('2023-03-11')).toEqual('2023-03-11T00:00:00Z');
   expect(normalizeTimestamp('2023-08-01')).toEqual('2023-08-01T00:00:00Z');
 });
+
+test('normalizeTimestamp should not rewrite timestamps with an explicit UTC offset', () => {
+  expect(normalizeTimestamp('2026-01-15T12:30:00+03:30')).toEqual(
+    '2026-01-15T12:30:00+03:30',
+  );
+  expect(normalizeTimestamp('2026-01-15T12:30:00Z')).toEqual(
+    '2026-01-15T12:30:00Z',
+  );
+  expect(normalizeTimestamp('2026-01-15T12:30:00-05:00')).toEqual(
+    '2026-01-15T12:30:00-05:00',
+  );
+});
+
+test('normalizeTimestamp inserts a colon into a compact offset', () => {
+  // `new Date(...)` only reliably parses `±hh:mm`; a colon-less offset is
+  // outside the ECMA-262 Date Time String Format and browsers disagree on
+  // how to parse it. The instant is unchanged, only the offset's spelling.
+  expect(normalizeTimestamp('2026-01-15 12:30:00+0330')).toEqual(
+    '2026-01-15 12:30:00+03:30',
+  );
+  expect(normalizeTimestamp('2026-01-15T12:30:00-0500')).toEqual(
+    '2026-01-15T12:30:00-05:00',
+  );
+  expect(
+    new Date(normalizeTimestamp('2026-01-15 12:30:00+0330')).getTime(),
+  ).toBe(new Date('2026-01-15T12:30:00+03:30').getTime());
+});
+
+test('normalizeTimestamp keeps Trino space-separated offsets intact', () => {
+  // Trino renders `timestamp with time zone` with a space before the
+  // offset; the old guard missed that form and TS_REGEX then stripped
+  // the offset, relabeling wall-clock time as UTC.
+  expect(normalizeTimestamp('2023-03-11 08:26:52.695 +03:00')).toEqual(
+    '2023-03-11 08:26:52.695 +03:00',
+  );
+  expect(normalizeTimestamp('2023-03-11 08:26:52 -05:00')).toEqual(
+    '2023-03-11 08:26:52 -05:00',
+  );
+});
+
+test('normalizeTimestamp preserves fractional seconds on offset-carrying values', () => {
+  // DateTime64(3) arrives with milliseconds: the offset-aware passthrough
+  // must keep them verbatim, not truncate to whole seconds.
+  expect(normalizeTimestamp('2026-01-15T12:30:00.123000+03:30')).toEqual(
+    '2026-01-15T12:30:00.123000+03:30',
+  );
+  expect(normalizeTimestamp('2026-01-15T12:30:00.123+03:30')).toEqual(
+    '2026-01-15T12:30:00.123+03:30',
+  );
+});
+
+test('a DST fall-back pair keeps distinct instants distinct', () => {
+  // 2026-11-01 is the US DST fall-back: identical wall times with -04:00
+  // (still EDT) and -05:00 (after the switch) are one hour apart. The old
+  // normalizer collapsed both to the same epoch by rewriting the offset
+  // to Z; the passthrough must keep them distinguishable.
+  const edt = '2026-11-01T01:30:00-04:00';
+  const est = '2026-11-01T01:30:00-05:00';
+  expect(normalizeTimestamp(edt)).toEqual(edt);
+  expect(normalizeTimestamp(est)).toEqual(est);
+  expect(new Date(normalizeTimestamp(edt)).getTime()).toBe(
+    new Date('2026-11-01T05:30:00Z').getTime(),
+  );
+  expect(new Date(normalizeTimestamp(est)).getTime()).toBe(
+    new Date('2026-11-01T06:30:00Z').getTime(),
+  );
+  expect(new Date(normalizeTimestamp(edt)).getTime()).not.toBe(
+    new Date(normalizeTimestamp(est)).getTime(),
+  );
+});