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(),
+ );
+});