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 52d8b405c99 fix(echarts): recognize Date and ISO-string temporal 
x-axis values in getXAxisDomain (#44818)
52d8b405c99 is described below

commit 52d8b405c990f831e47585126c97e58125fe1a9e
Author: Evan Rusackas <[email protected]>
AuthorDate: Fri Oct 2 18:26:15 2026 -0700

    fix(echarts): recognize Date and ISO-string temporal x-axis values in 
getXAxisDomain (#44818)
    
    Co-authored-by: Claude Sonnet 5 <[email protected]>
---
 .../plugin-chart-echarts/src/utils/formatters.ts   |  40 ++++++-
 .../plugin-chart-echarts/src/utils/series.ts       |  28 +----
 .../test/utils/formatters.test.ts                  | 118 +++++++++++++++++++++
 .../plugin-chart-echarts/test/utils/series.test.ts |  96 ++++++++++++++++-
 4 files changed, 253 insertions(+), 29 deletions(-)

diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts
index 264a35ba994..e7080c3c65e 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/formatters.ts
@@ -349,6 +349,42 @@ export function createSpacedXAxisFormatter(
   return wrapper;
 }
 
+// `new Date('2024-04-06')` parses as UTC, but ECharts' own date parser treats
+// zone-less strings as local time — mismatch would offset the pinned tick.
+const DATE_ONLY_RE = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?$/;
+
+function parseTemporalString(value: string): number {
+  const dateOnly = DATE_ONLY_RE.exec(value);
+  if (dateOnly) {
+    const [, year, month, day] = dateOnly;
+    return new Date(
+      Number(year),
+      Number(month || 1) - 1,
+      Number(day || 1),
+    ).getTime();
+  }
+  return new Date(value).getTime();
+}
+
+/**
+ * Coerces a data record's raw x-axis value to epoch milliseconds. A temporal
+ * column can carry any of the shapes the query/data pipeline produces for it
+ * (a raw epoch number, a `Date` from client-side parsing, or an ISO string
+ * straight off the wire) — every caller comparing or bucketing temporal
+ * values needs to recognize all three the same way, so this is the single
+ * place that does it. Returns `NaN` for a value that isn't temporal at all
+ * (nullish, an unparseable string, ...); callers filter with
+ * `Number.isFinite`.
+ */
+export function coerceTemporalMs(value: unknown): number {
+  // eslint-disable-next-line no-nested-ternary
+  return value instanceof Date
+    ? value.getTime()
+    : typeof value === 'string'
+      ? parseTemporalString(value)
+      : Number(value ?? NaN);
+}
+
 /**
  * Computes the [min, max] of a temporal x-axis column across one or more
  * data record arrays, for use with createSpacedXAxisFormatter.
@@ -361,8 +397,8 @@ export function getXAxisDomain(
   let domainMax: number | undefined;
   dataRecordArrays.forEach(records => {
     records.forEach(record => {
-      const value = record[xAxisCol];
-      if (typeof value === 'number') {
+      const value = coerceTemporalMs(record[xAxisCol]);
+      if (Number.isFinite(value)) {
         if (domainMin === undefined || value < domainMin) domainMin = value;
         if (domainMax === undefined || value > domainMax) domainMax = value;
       }
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
index 09dfca1f9b4..789f0d21b81 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts
@@ -55,7 +55,7 @@ import {
   StackType,
 } from '../types';
 import { defaultLegendPadding } from '../defaults';
-import { getXAxisDomain } from './formatters';
+import { coerceTemporalMs, getXAxisDomain } from './formatters';
 
 function isDefined<T>(value: T | undefined | null): boolean {
   return value !== undefined && value !== null;
@@ -1096,23 +1096,6 @@ export function getAxisType(
   return AxisType.Category;
 }
 
-// `new Date('2024-04-06')` parses as UTC, but ECharts' own date parser treats
-// zone-less strings as local time — mismatch would offset the pinned tick.
-const DATE_ONLY_RE = /^(\d{4})(?:-(\d{1,2})(?:-(\d{1,2}))?)?$/;
-
-function parseTemporalString(value: string): number {
-  const dateOnly = DATE_ONLY_RE.exec(value);
-  if (dateOnly) {
-    const [, year, month, day] = dateOnly;
-    return new Date(
-      Number(year),
-      Number(month || 1) - 1,
-      Number(day || 1),
-    ).getTime();
-  }
-  return new Date(value).getTime();
-}
-
 /**
  * Bucket timestamps a temporal axis should tick on, or undefined to let 
ECharts
  * choose.
@@ -1137,14 +1120,7 @@ export function getTemporalTickValues(
   }
   const values = new Set<number>();
   data.forEach(row => {
-    const value = row[xAxisLabel];
-    const timestamp =
-      // eslint-disable-next-line no-nested-ternary
-      value instanceof Date
-        ? value.getTime()
-        : typeof value === 'string'
-          ? parseTemporalString(value)
-          : Number(value ?? NaN);
+    const timestamp = coerceTemporalMs(row[xAxisLabel]);
     if (Number.isFinite(timestamp)) {
       values.add(timestamp);
     }
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts 
b/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts
index 9efde35ad5a..b424f77d023 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/test/utils/formatters.test.ts
@@ -27,9 +27,11 @@ import {
   TimeGranularity,
 } from '@superset-ui/core';
 import {
+  coerceTemporalMs,
   createSpacedXAxisFormatter,
   getPercentFormatter,
   getTooltipTimeFormatter,
+  getXAxisDomain,
   getXAxisFormatter,
 } from '../../src/utils/formatters';
 
@@ -386,3 +388,119 @@ test('createSpacedXAxisFormatter still dedupes identical 
consecutive labels when
 
   expect(labels).toEqual(['Jan', '', '']);
 });
+
+describe('coerceTemporalMs', () => {
+  test('passes a number through unchanged', () => {
+    expect(coerceTemporalMs(1712361600000)).toBe(1712361600000);
+  });
+
+  test('reads a Date object via getTime', () => {
+    const date = new Date(Date.UTC(2024, 3, 6));
+    expect(coerceTemporalMs(date)).toBe(date.getTime());
+  });
+
+  test('parses a zoned ISO string as the instant it names', () => {
+    expect(coerceTemporalMs('2026-04-06T00:00:00.000Z')).toBe(
+      Date.UTC(2026, 3, 6),
+    );
+  });
+
+  test('parses a zone-less datetime string as local time, matching ECharts', 
() => {
+    expect(coerceTemporalMs('2026-04-06T00:00:00')).toBe(
+      new Date(2026, 3, 6, 0, 0, 0).getTime(),
+    );
+  });
+
+  test('parses a bare date string as local midnight, matching ECharts rather 
than native Date', () => {
+    // `new Date('2026-04-06')` is UTC, but ECharts parses it as local time.
+    // jest.config.js fixes the test TZ to America/New_York, so they disagree.
+    const localMidnight = new Date(2026, 3, 6).getTime();
+    expect(localMidnight).not.toEqual(new Date('2026-04-06').getTime());
+    expect(coerceTemporalMs('2026-04-06')).toBe(localMidnight);
+  });
+
+  test('returns NaN for an unparseable string or a nullish value', () => {
+    expect(coerceTemporalMs('not-a-date')).toBeNaN();
+    expect(coerceTemporalMs(null)).toBeNaN();
+    expect(coerceTemporalMs(undefined)).toBeNaN();
+  });
+});
+
+describe('getXAxisDomain', () => {
+  const xAxisCol = '__timestamp';
+  const t0 = Date.UTC(2024, 0, 1);
+  const t1 = Date.UTC(2024, 0, 2);
+
+  test('finds the min/max of a purely numeric column', () => {
+    expect(
+      getXAxisDomain([[{ [xAxisCol]: t0 }, { [xAxisCol]: t1 }]], xAxisCol),
+    ).toEqual([t0, t1]);
+  });
+
+  test('finds the min/max when every value is a Date object', () => {
+    // Regression: getXAxisDomain used to only recognize `typeof === 'number'`,
+    // so a column of Date objects (as MixedTimeseries/Timeseries 
transformProps
+    // pass through in some data-fetch paths) found no bounds at all and the
+    // grain-aware bar-width cap silently fell back to the flat 100px default.
+    expect(
+      getXAxisDomain(
+        [[{ [xAxisCol]: new Date(t0) }, { [xAxisCol]: new Date(t1) }]],
+        xAxisCol,
+      ),
+    ).toEqual([t0, t1]);
+  });
+
+  test('finds the min/max when every value is an ISO string', () => {
+    expect(
+      getXAxisDomain(
+        [
+          [
+            { [xAxisCol]: '2024-01-01T00:00:00.000Z' },
+            { [xAxisCol]: '2024-01-02T00:00:00.000Z' },
+          ],
+        ],
+        xAxisCol,
+      ),
+    ).toEqual([t0, t1]);
+  });
+
+  test('finds the min/max across a mix of numbers, Dates and ISO strings', () 
=> {
+    // The two query result sets MixedTimeseries combines don't have to agree
+    // on representation; every row must still count toward the same domain.
+    expect(
+      getXAxisDomain(
+        [
+          [{ [xAxisCol]: t0 }],
+          [
+            { [xAxisCol]: new Date(t1) },
+            { [xAxisCol]: '2024-01-01T12:00:00.000Z' },
+          ],
+        ],
+        xAxisCol,
+      ),
+    ).toEqual([t0, t1]);
+  });
+
+  test('ignores unparseable or nullish values without affecting the real 
bounds', () => {
+    expect(
+      getXAxisDomain(
+        [
+          [
+            { [xAxisCol]: t0 },
+            { [xAxisCol]: 'not-a-date' },
+            { [xAxisCol]: null },
+            { [xAxisCol]: t1 },
+          ],
+        ],
+        xAxisCol,
+      ),
+    ).toEqual([t0, t1]);
+  });
+
+  test('returns [undefined, undefined] when the column has no temporal 
values', () => {
+    expect(getXAxisDomain([[{ other: 1 }]], xAxisCol)).toEqual([
+      undefined,
+      undefined,
+    ]);
+  });
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts 
b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts
index ab57ad0903c..6a6bf084f01 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts
@@ -41,6 +41,7 @@ import {
   getLegendProps,
   getLegendScrollDataIndex,
   getOverMaxHiddenFormatter,
+  getGrainBarMaxWidth,
   getMinAndMaxFromBounds,
   capTickMarks,
   getTemporalTickValues,
@@ -57,7 +58,12 @@ import {
   LegendType,
 } from '../../src/types';
 import { defaultLegendPadding } from '../../src/defaults';
-import { NULL_STRING, StackControlsValue } from '../../src/constants';
+import {
+  NULL_STRING,
+  ONE_DAY_MS,
+  StackControlsValue,
+  TIMEGRAIN_TO_TIMESTAMP,
+} from '../../src/constants';
 
 const {
   getHorizontalLegendAvailableWidth,
@@ -1937,6 +1943,94 @@ describe('getTemporalTickValues', () => {
   });
 });
 
+describe('getGrainBarMaxWidth', () => {
+  const xAxisCol = '__timestamp';
+  const plotLengthPx = 600;
+
+  test('returns undefined for a non-time axis', () => {
+    expect(
+      getGrainBarMaxWidth(
+        AxisType.Category,
+        TimeGranularity.HOUR,
+        [[{ [xAxisCol]: 0 }]],
+        xAxisCol,
+        plotLengthPx,
+      ),
+    ).toBeUndefined();
+  });
+
+  test('returns undefined when there is no resolved time grain', () => {
+    expect(
+      getGrainBarMaxWidth(
+        AxisType.Time,
+        undefined,
+        [[{ [xAxisCol]: 0 }]],
+        xAxisCol,
+        plotLengthPx,
+      ),
+    ).toBeUndefined();
+  });
+
+  test('computes the same grain-aware width whether the x column is numbers, 
Dates or ISO strings', () => {
+    // Regression: getGrainBarMaxWidth delegates to getXAxisDomain, which used
+    // to only recognize `typeof === 'number'`. A Date- or ISO-string-valued
+    // temporal column found no domain bounds and this returned undefined,
+    // silently falling back to the flat 100px sparse-bar cap instead of the
+    // grain-aware one.
+    const hour = TIMEGRAIN_TO_TIMESTAMP[TimeGranularity.HOUR];
+    const t0 = Date.UTC(2024, 0, 1, 0);
+    const t1 = Date.UTC(2024, 0, 1, 3);
+    const expected = (hour / (t1 - t0)) * plotLengthPx;
+
+    const numeric = getGrainBarMaxWidth(
+      AxisType.Time,
+      TimeGranularity.HOUR,
+      [[{ [xAxisCol]: t0 }, { [xAxisCol]: t1 }]],
+      xAxisCol,
+      plotLengthPx,
+    );
+    const dates = getGrainBarMaxWidth(
+      AxisType.Time,
+      TimeGranularity.HOUR,
+      [[{ [xAxisCol]: new Date(t0) }, { [xAxisCol]: new Date(t1) }]],
+      xAxisCol,
+      plotLengthPx,
+    );
+    const isoStrings = getGrainBarMaxWidth(
+      AxisType.Time,
+      TimeGranularity.HOUR,
+      [
+        [
+          { [xAxisCol]: '2024-01-01T00:00:00.000Z' },
+          { [xAxisCol]: '2024-01-01T03:00:00.000Z' },
+        ],
+      ],
+      xAxisCol,
+      plotLengthPx,
+    );
+
+    expect(numeric).toBeCloseTo(expected);
+    expect(dates).toBeCloseTo(expected);
+    expect(isoStrings).toBeCloseTo(expected);
+  });
+
+  test('falls back to the 2-day degenerate-domain span for a single distinct 
Date value', () => {
+    const hour = TIMEGRAIN_TO_TIMESTAMP[TimeGranularity.HOUR];
+    const t0 = new Date(Date.UTC(2024, 0, 1));
+    const expected = (hour / (2 * ONE_DAY_MS)) * plotLengthPx;
+
+    expect(
+      getGrainBarMaxWidth(
+        AxisType.Time,
+        TimeGranularity.HOUR,
+        [[{ [xAxisCol]: t0 }, { [xAxisCol]: new Date(t0.getTime()) }]],
+        xAxisCol,
+        plotLengthPx,
+      ),
+    ).toBeCloseTo(expected);
+  });
+});
+
 describe('capTickMarks', () => {
   test('returns values unchanged when within the cap', () => {
     const values = [1, 2, 3];

Reply via email to