From c43effa4a321f64ce2ddde92ac80e3263740a4ed Mon Sep 17 00:00:00 2001 From: jesperct Date: Thu, 23 Jul 2026 21:14:30 -0300 Subject: [PATCH] fix(explore): show the beginning date on time-series x-axis line charts (#42046) --- .../src/MixedTimeseries/transformProps.ts | 17 +++++ .../src/Timeseries/transformProps.ts | 25 ++++++- .../MixedTimeseries/transformProps.test.ts | 55 +++++++++++++++ .../test/Timeseries/transformProps.test.ts | 69 +++++++++++++++++++ 4 files changed, 163 insertions(+), 3 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index 5e3599234a5..ee01fb6f89c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -641,7 +641,22 @@ export default function transformProps( const deduplicatedFormatter = showMaxLabel ? (() => { let lastLabel: string | undefined; + let lastValue: number | undefined; const wrapper = (value: number | string) => { + // ECharts formats the labels in repeated ascending passes. Reset the + // dedup state when the sequence restarts so a forced boundary label + // (e.g. the min date) isn't blanked by the previous pass's last label + // when both format identically (e.g. a May-to-May range). + if ( + typeof value === 'number' && + lastValue !== undefined && + value <= lastValue + ) { + lastLabel = undefined; + } + if (typeof value === 'number') { + lastValue = value; + } const label = typeof xAxisFormatter === 'function' ? (xAxisFormatter as Function)(value) @@ -743,6 +758,8 @@ export default function transformProps( ...(showMaxLabel && { showMaxLabel: true, alignMaxLabel: 'right', + showMinLabel: true, + alignMinLabel: 'left', }), }, minorTick: { show: minorTicks }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index a2ad93e9578..1493aeee897 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -883,7 +883,22 @@ export default function transformProps( const deduplicatedFormatter = showMaxLabel ? (() => { let lastLabel: string | undefined; + let lastValue: number | undefined; const wrapper = (value: number | string) => { + // ECharts formats the labels in repeated ascending passes. Reset the + // dedup state when the sequence restarts so a forced boundary label + // (e.g. the min date) isn't blanked by the previous pass's last label + // when both format identically (e.g. a May-to-May range). + if ( + typeof value === 'number' && + lastValue !== undefined && + value <= lastValue + ) { + lastLabel = undefined; + } + if (typeof value === 'number') { + lastValue = value; + } const label = typeof xAxisFormatter === 'function' ? (xAxisFormatter as Function)(value) @@ -921,12 +936,16 @@ export default function transformProps( formatter: deduplicatedFormatter, rotate: xAxisLabelRotation, interval: xAxisLabelInterval, - // Force last label on non-rotated time axes to prevent - // hideOverlap from hiding it. Skipped when rotated to - // avoid phantom labels at the axis boundary. + // Force the boundary labels on non-rotated time axes so the first + // and last dates stay visible: hideOverlap can hide the last label, + // and a min date that falls between "nice" ticks otherwise renders + // no beginning label. Skipped when rotated to avoid phantom labels + // at the axis boundary. ...(showMaxLabel && { showMaxLabel: true, alignMaxLabel: 'right', + showMinLabel: true, + alignMinLabel: 'left', }), }, minorTick: { show: minorTicks }, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts index fc189cb8358..84c1765b83b 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/MixedTimeseries/transformProps.test.ts @@ -1106,3 +1106,58 @@ test('tooltip time grain wiring: chart-level time grain drives the tooltip when expect(result).toContain('2021'); expect(result).not.toContain('2021-01-07'); }); + +const createTemporalMixedChartProps = (timeFormat: string) => { + const rows = [ + { __timestamp: Date.UTC(2003, 4, 1), metric: 10 }, + { __timestamp: Date.UTC(2004, 0, 1), metric: 20 }, + { __timestamp: Date.UTC(2005, 4, 1), metric: 30 }, + ]; + const q = createTestQueryData(rows, { + colnames: ['__timestamp', 'metric'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + label_map: { __timestamp: ['__timestamp'], metric: ['metric'] }, + }); + return createEchartsTimeseriesTestChartProps< + EchartsMixedTimeseriesFormData, + EchartsMixedTimeseriesProps + >({ + ...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS, + defaultQueriesData: [q, q], + formData: { + ...formData, + x_axis: '__timestamp', + metrics: ['metric'], + metricsB: ['metric'], + groupby: [], + groupbyB: [], + timeGrainSqla: TimeGranularity.MONTH, + xAxisTimeFormat: timeFormat, + }, + queriesData: [q, q], + }); +}; + +test('x-axis forces showMinLabel for time grains so the beginning date stays visible (mixed)', () => { + const xAxis = transformProps(createTemporalMixedChartProps('smart_date')) + .echartOptions.xAxis as any; + expect(xAxis.axisLabel.showMinLabel).toBe(true); +}); + +test('x-axis dedup keeps the forced min label when the endpoints format identically (mixed)', () => { + // May→May range renders "May" at both boundaries; the dedup must reset per + // ECharts pass so the forced min label survives the second pass. + const { formatter } = ( + transformProps(createTemporalMixedChartProps('%b')).echartOptions + .xAxis as any + ).axisLabel; + const min = Date.UTC(2003, 4, 1); + const mid = Date.UTC(2004, 0, 1); + const max = Date.UTC(2005, 4, 1); + + formatter(min); + formatter(mid); + formatter(max); + + expect(formatter(min)).toBe('May'); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index a219c22f56c..9f51bcfcca8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -1517,6 +1517,45 @@ test('x-axis formatter deduplicates consecutive identical labels for coarse time expect(label4).toBe(''); }); +test('x-axis dedup keeps the forced min label when the endpoints format identically', () => { + // A May→May range renders "May" at both boundaries. ECharts formats labels in + // repeated ascending passes; the dedup must reset per pass so the forced min + // label isn't blanked by the previous pass's (identical) max label. + const data = [ + { __timestamp: Date.UTC(2003, 4, 1), sales: 100 }, + { __timestamp: Date.UTC(2004, 0, 1), sales: 200 }, + { __timestamp: Date.UTC(2005, 4, 1), sales: 300 }, + ]; + + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.MONTH, + xAxisTimeFormat: '%b', + }, + queriesData: [ + createTestQueryData(data, { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }), + ], + }); + + const { formatter } = (transformProps(chartProps).echartOptions.xAxis as any) + .axisLabel; + const min = Date.UTC(2003, 4, 1); + const mid = Date.UTC(2004, 0, 1); + const max = Date.UTC(2005, 4, 1); + + // First pass fills the dedup state, ending on the max label ("May"). + formatter(min); + formatter(mid); + formatter(max); + + // Second pass restarts at the min; it must not be blanked by the prior "May". + expect(formatter(min)).toBe('May'); +}); + test('x-axis does not force showMaxLabel when no time grain is set', () => { const data = [ { __timestamp: Date.UTC(2003, 0, 6), sales: 100 }, @@ -1539,6 +1578,36 @@ test('x-axis does not force showMaxLabel when no time grain is set', () => { const xAxisResult = transformProps(chartProps).echartOptions.xAxis as any; expect(xAxisResult.axisLabel.showMaxLabel).not.toBe(true); + expect(xAxisResult.axisLabel.showMinLabel).not.toBe(true); +}); + +test('x-axis forces showMinLabel for time grains so the beginning date stays visible', () => { + // When the first data point is not on a coarse boundary (e.g. a mid-year + // month), ECharts places its first label on the next "nice" tick and leaves + // the axis-min date unlabeled. showMinLabel forces the beginning date to + // render, symmetric to showMaxLabel on the trailing edge. + const monthData = [ + { __timestamp: Date.UTC(2003, 4, 1), sales: 100 }, + { __timestamp: Date.UTC(2003, 5, 1), sales: 200 }, + { __timestamp: Date.UTC(2003, 6, 1), sales: 300 }, + ]; + + const chartProps = createTestChartProps({ + formData: { + granularity_sqla: 'ds', + timeGrainSqla: TimeGranularity.MONTH, + xAxisTimeFormat: 'smart_date', + }, + queriesData: [ + createTestQueryData(monthData, { + colnames: ['__timestamp', 'sales'], + coltypes: [GenericDataType.Temporal, GenericDataType.Numeric], + }), + ], + }); + + const xAxisResult = transformProps(chartProps).echartOptions.xAxis as any; + expect(xAxisResult.axisLabel.showMinLabel).toBe(true); }); test('numeric x coltype routes through the number formatter (not the time formatter)', () => {