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/08/21 22:45:13 UTC

(superset) branch master updated: feat(chart): enable cross-filter on temporal x-axis (bar/label click) (#42792)

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 09bd8c98de2 feat(chart): enable cross-filter on temporal x-axis (bar/label click) (#42792)
09bd8c98de2 is described below

commit 09bd8c98de214e27a1ff8e06be70ca68b62b4106
Author: abdullah reveha <15...@users.noreply.github.com>
AuthorDate: Sat Aug 22 01:45:06 2026 +0300

    feat(chart): enable cross-filter on temporal x-axis (bar/label click) (#42792)
    
    Co-authored-by: Abdullah Sahin <yo...@example.comclear>
---
 .../superset-ui-core/src/time-format/index.ts      |   1 +
 .../src/Timeseries/EchartsTimeseries.test.tsx      | 736 +++++++++++++++++++--
 .../src/Timeseries/EchartsTimeseries.tsx           | 205 ++++--
 .../src/Timeseries/transformProps.ts               |   3 +-
 .../plugin-chart-echarts/src/Timeseries/types.ts   |   1 +
 .../test/Timeseries/EchartsTimeseries.test.tsx     | 426 +++++++++++-
 .../test/Timeseries/transformProps.test.ts         |  35 +
 7 files changed, 1313 insertions(+), 94 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-core/src/time-format/index.ts b/superset-frontend/packages/superset-ui-core/src/time-format/index.ts
index 5b01bbca77b..e6023a02b59 100644
--- a/superset-frontend/packages/superset-ui-core/src/time-format/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/time-format/index.ts
@@ -49,5 +49,6 @@ export { default as finestTemporalGrainFormatter } from './formatters/finestTemp
 
 export { default as normalizeTimestamp } from './utils/normalizeTimestamp';
 export { default as denormalizeTimestamp } from './utils/denormalizeTimestamp';
+export { default as createTimeRangeFromGranularity } from './utils/createTimeRangeFromGranularity';
 
 export * from './types';
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
index 7829f18ac3d..2d2a23e95b5 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.test.tsx
@@ -21,8 +21,14 @@ import {
   waitFor,
   cleanup,
 } from '../../../../spec/helpers/testing-library';
-import { AxisType, TimeGranularity } from '@superset-ui/core';
+import {
+  AxisType,
+  createTimeRangeFromGranularity,
+  DTTM_ALIAS,
+  TimeGranularity,
+} from '@superset-ui/core';
 import { GenericDataType } from '@apache-superset/core/common';
+import { logging } from '@apache-superset/core/utils';
 import type { EChartsCoreOption } from 'echarts/core';
 import type { ECElementEvent } from 'echarts/types/src/util/types';
 import type { ReactNode } from 'react';
@@ -93,12 +99,17 @@ afterAll(() => {
 });
 
 afterEach(() => {
+  jest.useRealTimers();
   cleanup();
   mockEchart.mockReset();
   (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
     originalResizeObserver;
 });
 
+const advanceClickTimer = () => {
+  jest.advanceTimersByTime(300);
+};
+
 const defaultFormData: EchartsTimeseriesFormData & {
   vizType: string;
   dateFormat: string;
@@ -314,7 +325,8 @@ test('falls back to window resize listener when ResizeObserver is unavailable',
 });
 
 // Test for issue #25334: Bar chart cross-filter without dimensions
-test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', async () => {
+test('emits cross-filter on X-axis value when no dimensions and categorical X-axis', () => {
+  jest.useFakeTimers();
   const setDataMaskMock = jest.fn();
 
   const propsWithCategoricalXAxis: TimeseriesChartTransformedProps = {
@@ -348,13 +360,7 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
       dataIndex: 0,
     });
 
-    // Wait for the timer (TIMER_DURATION = 300ms)
-    await waitFor(
-      () => {
-        expect(setDataMaskMock).toHaveBeenCalled();
-      },
-      { timeout: 500 },
-    );
+    advanceClickTimer();
 
     // Verify the cross-filter uses the X-axis column and value, not the metric
     const dataMaskCall = setDataMaskMock.mock.calls[0][0];
@@ -368,7 +374,8 @@ test('emits cross-filter on X-axis value when no dimensions and categorical X-ax
   }
 });
 
-test('emits cross-filter on category value for horizontal bar clicks', async () => {
+test('emits cross-filter on category value for horizontal bar clicks', () => {
+  jest.useFakeTimers();
   const setDataMaskMock = jest.fn();
 
   render(
@@ -397,12 +404,7 @@ test('emits cross-filter on category value for horizontal bar clicks', async ()
     dataIndex: 0,
   });
 
-  await waitFor(
-    () => {
-      expect(setDataMaskMock).toHaveBeenCalled();
-    },
-    { timeout: 500 },
-  );
+  advanceClickTimer();
 
   expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
     {
@@ -424,9 +426,7 @@ test('uses rendered categorical axis for query event handlers', () => {
     />,
   );
 
-  expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
-    'xAxis.category',
-  );
+  expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('xAxis');
 
   cleanup();
   mockEchart.mockReset();
@@ -445,9 +445,7 @@ test('uses rendered categorical axis for query event handlers', () => {
     />,
   );
 
-  expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe(
-    'yAxis.category',
-  );
+  expect(getLatestEchartProps().queryEventHandlers?.[0].query).toBe('yAxis');
 });
 
 test('emits cross-filter from horizontal categorical axis label clicks', () => {
@@ -473,8 +471,9 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
     getLatestEchartProps().queryEventHandlers?.[0].handler;
   expect(labelClickHandler).toBeDefined();
   labelClickHandler?.({
+    targetType: 'axisLabel',
     value: 'Product A',
-  } as ECElementEvent);
+  } as unknown as ECElementEvent);
 
   expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
     {
@@ -485,7 +484,8 @@ test('emits cross-filter from horizontal categorical axis label clicks', () => {
   ]);
 });
 
-test('does not emit duplicate cross-filter for generic axis label clicks', async () => {
+test('does not emit duplicate cross-filter for generic axis label clicks', () => {
+  jest.useFakeTimers();
   const setDataMaskMock = jest.fn();
 
   render(
@@ -507,11 +507,28 @@ test('does not emit duplicate cross-filter for generic axis label clicks', async
     name: 'Product A',
   });
 
-  await new Promise(resolve => setTimeout(resolve, 400));
+  jest.advanceTimersByTime(400);
   expect(setDataMaskMock).not.toHaveBeenCalled();
 });
 
-test('does not emit cross-filter when no dimensions and time-based X-axis', async () => {
+test('keeps temporal range exclusive ends on whole-second boundaries', () => {
+  const clickedTimestamp = new Date(Date.UTC(2021, 0, 15, 12, 34, 56, 789));
+
+  [TimeGranularity.DAY, TimeGranularity.MONTH, TimeGranularity.YEAR].forEach(
+    grain => {
+      const [, inclusiveEnd] = createTimeRangeFromGranularity(
+        clickedTimestamp,
+        grain,
+        false,
+      );
+      const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
+
+      expect(exclusiveEnd.getUTCMilliseconds()).toBe(0);
+    },
+  );
+});
+
+test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
   const setDataMaskMock = jest.fn();
 
   const propsWithTimeXAxis: TimeseriesChartTransformedProps = {
@@ -519,39 +536,610 @@ test('does not emit cross-filter when no dimensions and time-based X-axis', asyn
     emitCrossFilters: true,
     setDataMask: setDataMaskMock,
     groupby: [], // No dimensions
+    resolvedTimeGrain: TimeGranularity.DAY,
+    formData: {
+      ...defaultFormData,
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.DAY,
+    },
     xAxis: {
-      label: '__timestamp',
-      type: AxisType.Time, // Time-based X-axis (not categorical)
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
     },
   };
 
   render(<EchartsTimeseries {...propsWithTimeXAxis} />);
 
-  const lastCall = mockEchart.mock.calls.at(-1);
-  expect(lastCall).toBeDefined();
-  const [props] = lastCall as [EchartsProps];
+  const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+    ({ query }) => query === 'xAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: '2021-01-01',
+  } as unknown as ECElementEvent);
 
-  // Simulate a click event
-  const clickHandler = props.eventHandlers?.click;
-  if (clickHandler) {
-    clickHandler({
-      componentType: 'series',
-      seriesName: 'Sales',
-      data: [1609459200000, 100], // Timestamp
-      name: '2021-01-01',
-      dataIndex: 0,
-    });
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
+    },
+  ]);
+});
 
-    // Wait a bit and verify setDataMask was NOT called
-    await new Promise(resolve => setTimeout(resolve, 400));
-    expect(setDataMaskMock).not.toHaveBeenCalled();
-  }
+test('emits TEMPORAL_RANGE cross-filter from time axis label click on month bucket', () => {
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+    ({ query }) => query === 'xAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: '2021-01-01',
+  } as unknown as ECElementEvent);
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('emits TEMPORAL_RANGE cross-filter from time axis label click on year bucket', () => {
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.YEAR}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.YEAR,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+    ({ query }) => query === 'xAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: '2021-01-01',
+  } as unknown as ECElementEvent);
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2022-01-01T00:00:00',
+    },
+  ]);
+});
+
+test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
+  jest.useFakeTimers();
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+
+  advanceClickTimer();
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('emits TEMPORAL_RANGE from string-typed time point click value', () => {
+  jest.useFakeTimers();
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: ['2021-01-01T00:00:00Z', 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+
+  advanceClickTimer();
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('uses resolved time grain for temporal point-click cross-filter', () => {
+  jest.useFakeTimers();
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.DAY,
+        extraFormData: {
+          time_grain_sqla: TimeGranularity.MONTH,
+        },
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+
+  advanceClickTimer();
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
+  jest.useFakeTimers();
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        orientation: OrientationType.Horizontal,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [129, Date.UTC(2021, 0, 1)],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+
+  advanceClickTimer();
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('emits TEMPORAL_RANGE cross-filter from horizontal time axis label click', () => {
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        orientation: OrientationType.Horizontal,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+    ({ query }) => query === 'yAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: '2021-01-01',
+  } as unknown as ECElementEvent);
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('warns and skips temporal axis label cross-filter when label value cannot be parsed', () => {
+  const setDataMaskMock = jest.fn();
+  const warn = jest.spyOn(logging, 'warn').mockImplementation();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+    ({ query }) => query === 'xAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: 'not-a-date',
+  } as unknown as ECElementEvent);
+
+  expect(setDataMaskMock).not.toHaveBeenCalled();
+  expect(warn).toHaveBeenCalledWith(
+    'Unable to parse time axis value for cross-filtering',
+    'not-a-date',
+  );
+
+  warn.mockRestore();
+});
+
+test('logs and skips temporal point-click cross-filter when string value cannot be parsed', () => {
+  jest.useFakeTimers();
+  const setDataMaskMock = jest.fn();
+  const warn = jest.spyOn(logging, 'warn').mockImplementation();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: ['not-a-date', 100],
+    name: 'not-a-date',
+    dataIndex: 0,
+  });
+
+  advanceClickTimer();
+
+  expect(setDataMaskMock).not.toHaveBeenCalled();
+  expect(warn).toHaveBeenCalledWith(
+    'Unable to parse time axis value for cross-filtering',
+    'not-a-date',
+  );
+
+  warn.mockRestore();
+});
+
+test('emits empty temporal X-axis data mask when filter grain is missing', () => {
+  jest.useFakeTimers();
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: undefined,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+
+  advanceClickTimer();
+
+  expect(setDataMaskMock.mock.calls[0][0]).toEqual({
+    extraFormData: {
+      filters: [],
+    },
+    filterState: {
+      label: undefined,
+      value: null,
+      selectedValues: null,
+    },
+  });
+});
+
+test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
+  jest.useFakeTimers();
+  const setDataMaskMock = jest.fn();
+  const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={[]}
+      selectedValues={{ 0: selectedRange }}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+
+  advanceClickTimer();
+
+  expect(setDataMaskMock.mock.calls[0][0]).toEqual({
+    extraFormData: {
+      filters: [],
+    },
+    filterState: {
+      label: undefined,
+      value: null,
+      selectedValues: null,
+    },
+  });
+});
+
+test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={['country']}
+      formData={{
+        ...defaultFormData,
+        groupby: ['country'],
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+    ({ query }) => query === 'xAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: '2021-01-01',
+  } as unknown as ECElementEvent);
+
+  expect(setDataMaskMock).not.toHaveBeenCalled();
+});
+
+test('does not emit temporal X-axis cross-filter when dimensions are set', () => {
+  jest.useFakeTimers();
+  const setDataMaskMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      setDataMask={setDataMaskMock}
+      groupby={['country']}
+      labelMap={{
+        Sales: ['US'],
+      }}
+      formData={{
+        ...defaultFormData,
+        groupby: ['country'],
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.MONTH,
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const clickHandler = getLatestEchartProps().eventHandlers?.click;
+  expect(clickHandler).toBeDefined();
+  clickHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+
+  advanceClickTimer();
+
+  expect(setDataMaskMock.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'country',
+      op: 'IN',
+      val: ['US'],
+    },
+  ]);
+  expect(
+    setDataMaskMock.mock.calls[0][0].extraFormData.filters.some(
+      (filter: { op: string }) => filter.op === 'TEMPORAL_RANGE',
+    ),
+  ).toBe(false);
 });
 
 // Test for issue #41102: horizontal bar cross-filter must use the category
 // value, not the metric. For horizontal bars the data tuple is value-first
 // (e.g. [100, 'Product A']), so relying on data[0] emitted the metric value.
-test('emits cross-filter on the category value for a horizontal categorical bar', async () => {
+test('emits cross-filter on the category value for a horizontal categorical bar', () => {
+  jest.useFakeTimers();
   const setDataMaskMock = jest.fn();
 
   const propsWithHorizontalXAxis: TimeseriesChartTransformedProps = {
@@ -585,12 +1173,7 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
       dataIndex: 0,
     });
 
-    await waitFor(
-      () => {
-        expect(setDataMaskMock).toHaveBeenCalled();
-      },
-      { timeout: 500 },
-    );
+    advanceClickTimer();
 
     // Must filter on the category ('Product A'), not the metric value (100)
     const dataMaskCall = setDataMaskMock.mock.calls[0][0];
@@ -604,6 +1187,55 @@ test('emits cross-filter on the category value for a horizontal categorical bar'
   }
 });
 
+test('context menu cross-filter is available for a temporal bar point', async () => {
+  const onContextMenuMock = jest.fn();
+
+  render(
+    <EchartsTimeseries
+      {...defaultProps}
+      emitCrossFilters
+      onContextMenu={onContextMenuMock}
+      groupby={[]}
+      resolvedTimeGrain={TimeGranularity.MONTH}
+      formData={{
+        ...defaultFormData,
+        granularitySqla: 'ds',
+        timeGrainSqla: TimeGranularity.DAY,
+        extraFormData: {
+          time_grain_sqla: TimeGranularity.MONTH,
+        },
+      }}
+      xAxis={{
+        label: DTTM_ALIAS,
+        type: AxisType.Time,
+      }}
+    />,
+  );
+
+  const contextMenuHandler = getLatestEchartProps().eventHandlers?.contextmenu;
+  expect(contextMenuHandler).toBeDefined();
+  await contextMenuHandler?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
+  });
+
+  await waitFor(() => {
+    expect(onContextMenuMock).toHaveBeenCalled();
+  });
+
+  const { crossFilter } = onContextMenuMock.mock.calls[0][2];
+  expect(crossFilter.dataMask.extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
 // Test for issue #41102: the context-menu ("Add cross-filter") path must also
 // use the category value, not the metric, for a horizontal categorical bar.
 test('context menu cross-filter uses the category value for a horizontal categorical bar', async () => {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
index 4665e1ee59f..fae555e33da 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -21,14 +21,17 @@ import {
   DTTM_ALIAS,
   BinaryQueryObjectFilterClause,
   AxisType,
+  type TimeGranularity,
   getTimeFormatter,
   getColumnLabel,
   getNumberFormatter,
   LegendState,
   ensureIsArray,
+  createTimeRangeFromGranularity,
 } from '@superset-ui/core';
 import { useTheme } from '@apache-superset/core/theme';
 import { GenericDataType } from '@apache-superset/core/common';
+import { logging } from '@apache-superset/core/utils';
 import type {
   ECElementEvent,
   ViewRootGroup,
@@ -48,6 +51,29 @@ import { getTemporalXAxisDrillByFilter } from '../utils/xAxisDrillByFilter';
 import { ExtraControls } from '../components/ExtraControls';
 
 const TIMER_DURATION = 300;
+const getTimestampFromTimeAxisValue = (value: string | number) => {
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? value : undefined;
+  }
+  const timestamp = Date.parse(value);
+  if (Number.isNaN(timestamp)) {
+    logging.warn('Unable to parse time axis value for cross-filtering', value);
+  }
+  return Number.isNaN(timestamp) ? undefined : timestamp;
+};
+
+// Day, month, and year ranges end at 23:59:59.999, so adding 1ms lands on a
+// whole-second next bucket boundary. The formatter intentionally emits seconds.
+const formatDateTime = (date: Date) =>
+  `${[
+    date.getUTCFullYear(),
+    String(date.getUTCMonth() + 1).padStart(2, '0'),
+    String(date.getUTCDate()).padStart(2, '0'),
+  ].join('-')}T${[
+    String(date.getUTCHours()).padStart(2, '0'),
+    String(date.getUTCMinutes()).padStart(2, '0'),
+    String(date.getUTCSeconds()).padStart(2, '0'),
+  ].join(':')}`;
 
 // Percent-change draggable baseline handle geometry, in pixels.
 const BASELINE_HANDLE_WIDTH = 8;
@@ -71,6 +97,7 @@ export default function EchartsTimeseries({
   onFocusedSeries,
   xValueFormatter,
   xAxis,
+  resolvedTimeGrain,
   refs,
   emitCrossFilters,
   coltypeMapping,
@@ -363,6 +390,65 @@ export default function EchartsTimeseries({
     [selectedValues, xAxis.label],
   );
 
+  const getTimeAxisCrossFilterDataMask = useCallback(
+    (clickedTimestamp: number) => {
+      const filterColumn =
+        xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
+      const grain = resolvedTimeGrain as TimeGranularity | undefined;
+
+      if (!filterColumn || !grain) {
+        return {
+          dataMask: {
+            extraFormData: {
+              filters: [],
+            },
+            filterState: {
+              label: undefined,
+              value: null,
+              selectedValues: null,
+            },
+          },
+          isCurrentValueSelected: false,
+        };
+      }
+
+      const [start, inclusiveEnd] = createTimeRangeFromGranularity(
+        new Date(clickedTimestamp),
+        grain,
+        false,
+      );
+      const exclusiveEnd = new Date(inclusiveEnd.getTime() + 1);
+      const timeRange = `${formatDateTime(start)} : ${formatDateTime(exclusiveEnd)}`;
+      const selected: string[] = Object.values(selectedValues);
+      const isCurrentValueSelected = selected.includes(timeRange);
+      const values = isCurrentValueSelected ? [] : [timeRange];
+
+      return {
+        dataMask: {
+          extraFormData: {
+            filters:
+              values.length === 0
+                ? []
+                : [
+                    {
+                      col: filterColumn,
+                      op: 'TEMPORAL_RANGE' as const,
+                      val: timeRange,
+                    },
+                  ],
+          },
+          filterState: {
+            label: values.length ? values : undefined,
+            value: values.length ? values : null,
+            selectedValues: values.length ? values : null,
+          },
+        },
+        isCurrentValueSelected,
+      };
+    },
+    [formData.granularitySqla, resolvedTimeGrain, selectedValues, xAxis.label],
+  );
+
   const handleChange = useCallback(
     (value: string) => {
       if (!emitCrossFilters) {
@@ -384,15 +470,26 @@ export default function EchartsTimeseries({
     [emitCrossFilters, setDataMask, getXAxisCrossFilterDataMask],
   );
 
+  const handleTimeAxisChange = useCallback(
+    (clickedTimestamp: number) => {
+      if (!emitCrossFilters) {
+        return;
+      }
+      setDataMask(getTimeAxisCrossFilterDataMask(clickedTimestamp).dataMask);
+    },
+    [emitCrossFilters, setDataMask, getTimeAxisCrossFilterDataMask],
+  );
+
   // Determine if X-axis can be used for cross-filtering (categorical axis without dimensions)
   const canCrossFilterByXAxis =
-    !hasDimensions && xAxis.type === AxisType.Category;
-  const categoryAxisValueIndex =
+    !hasDimensions &&
+    (xAxis.type === AxisType.Category || xAxis.type === AxisType.Time);
+  const xAxisValueIndex =
     formData.orientation === OrientationType.Horizontal ? 1 : 0;
-  const getCategoryAxisValue = useCallback(
+  const getXAxisValue = useCallback(
     (data: unknown, name: unknown) => {
       if (Array.isArray(data)) {
-        const categoryAxisValue = data[categoryAxisValueIndex];
+        const categoryAxisValue = data[xAxisValueIndex];
         if (
           typeof categoryAxisValue === 'string' ||
           typeof categoryAxisValue === 'number'
@@ -405,7 +502,7 @@ export default function EchartsTimeseries({
       }
       return undefined;
     },
-    [categoryAxisValueIndex],
+    [xAxisValueIndex],
   );
 
   const eventHandlers: EventHandlers = {
@@ -423,15 +520,28 @@ export default function EchartsTimeseries({
           // Cross-filter by dimension (original behavior)
           const { seriesName: name } = props;
           handleChange(name);
-        } else if (canCrossFilterByXAxis && props.componentType === 'series') {
+        } else if (
+          canCrossFilterByXAxis &&
+          xAxis.type === AxisType.Category &&
+          props.componentType === 'series'
+        ) {
           // Cross-filter by X-axis value when no dimensions (issue #25334)
-          const categoryAxisValue = getCategoryAxisValue(
-            props.data,
-            props.name,
-          );
+          const categoryAxisValue = getXAxisValue(props.data, props.name);
           if (categoryAxisValue !== undefined) {
             handleXAxisChange(categoryAxisValue);
           }
+        } else if (
+          canCrossFilterByXAxis &&
+          xAxis.type === AxisType.Time &&
+          props.componentType === 'series'
+        ) {
+          const timeAxisValue = getXAxisValue(props.data, props.name);
+          if (timeAxisValue !== undefined) {
+            const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
+            if (timestamp !== undefined) {
+              handleTimeAxisChange(timestamp);
+            }
+          }
         }
       }, TIMER_DURATION);
     },
@@ -466,17 +576,20 @@ export default function EchartsTimeseries({
         ];
         const groupBy = ensureIsArray(formData.groupby);
         if (data && xAxis.type === AxisType.Time) {
-          drillToDetailFilters.push({
-            col:
-              // if the xAxis is '__timestamp', granularity_sqla will be the column of filter
-              xAxis.label === DTTM_ALIAS
-                ? formData.granularitySqla
-                : xAxis.label,
-            grain: formData.timeGrainSqla,
-            op: '==',
-            val: data[0],
-            formattedVal: xValueFormatter(data[0]),
-          });
+          const timeAxisValue = getXAxisValue(data, eventParams.name);
+          if (timeAxisValue !== undefined) {
+            drillToDetailFilters.push({
+              col:
+                // if the xAxis is '__timestamp', granularity_sqla will be the column of filter
+                xAxis.label === DTTM_ALIAS
+                  ? formData.granularitySqla
+                  : xAxis.label,
+              grain: resolvedTimeGrain,
+              op: '==',
+              val: timeAxisValue,
+              formattedVal: xValueFormatter(timeAxisValue),
+            });
+          }
         }
         [
           ...(xAxis.type === AxisType.Category && data ? [xAxis.label] : []),
@@ -517,9 +630,7 @@ export default function EchartsTimeseries({
           xAxis.label === DTTM_ALIAS ? formData.granularitySqla : xAxis.label;
         if (data && xAxis.type === AxisType.Time && xAxisCol) {
           // For horizontal orientation the [x, value] pair is swapped
-          const xValue = Array.isArray(data)
-            ? data[categoryAxisValueIndex]
-            : data;
+          const xValue = Array.isArray(data) ? data[xAxisValueIndex] : data;
           const xAxisFilter = getTemporalXAxisDrillByFilter(
             xAxisCol,
             xValue,
@@ -530,10 +641,7 @@ export default function EchartsTimeseries({
             xAxisFilters.push(xAxisFilter);
           }
         } else if (xAxis.type === AxisType.Category && xAxisCol) {
-          const categoryAxisValue = getCategoryAxisValue(
-            data,
-            eventParams.name,
-          );
+          const categoryAxisValue = getXAxisValue(data, eventParams.name);
           if (categoryAxisValue !== undefined) {
             // A category axis can still sit on a temporal column when the
             // axis is forced categorical; filter by time bucket in that case
@@ -564,15 +672,25 @@ export default function EchartsTimeseries({
           crossFilter = getCrossFilterDataMask(seriesName);
         } else if (
           canCrossFilterByXAxis &&
+          xAxis.type === AxisType.Category &&
           eventParams.componentType === 'series'
         ) {
-          const categoryAxisValue = getCategoryAxisValue(
-            data,
-            eventParams.name,
-          );
+          const categoryAxisValue = getXAxisValue(data, eventParams.name);
           if (categoryAxisValue !== undefined) {
             crossFilter = getXAxisCrossFilterDataMask(categoryAxisValue);
           }
+        } else if (
+          canCrossFilterByXAxis &&
+          xAxis.type === AxisType.Time &&
+          eventParams.componentType === 'series'
+        ) {
+          const timeAxisValue = getXAxisValue(data, eventParams.name);
+          if (timeAxisValue !== undefined) {
+            const timestamp = getTimestampFromTimeAxisValue(timeAxisValue);
+            if (timestamp !== undefined) {
+              crossFilter = getTimeAxisCrossFilterDataMask(timestamp);
+            }
+          }
         }
 
         onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
@@ -593,26 +711,39 @@ export default function EchartsTimeseries({
       const { value } = event;
       if (
         canCrossFilterByXAxis &&
+        event.targetType === 'axisLabel' &&
         (typeof value === 'string' || typeof value === 'number')
       ) {
-        handleXAxisChange(value);
+        if (xAxis.type === AxisType.Time) {
+          const timestamp = getTimestampFromTimeAxisValue(value);
+          if (timestamp !== undefined) {
+            handleTimeAxisChange(timestamp);
+          }
+        } else {
+          handleXAxisChange(value);
+        }
       }
     },
-    [canCrossFilterByXAxis, handleXAxisChange],
+    [
+      canCrossFilterByXAxis,
+      handleTimeAxisChange,
+      handleXAxisChange,
+      xAxis.type,
+    ],
   );
 
-  const categoryAxis =
+  const renderedXAxis =
     formData.orientation === OrientationType.Horizontal ? 'yAxis' : 'xAxis';
 
   const queryEventHandlers = useMemo(
     () => [
       {
         name: 'click',
-        query: `${categoryAxis}.category`,
+        query: renderedXAxis,
         handler: handleXAxisLabelClick,
       },
     ],
-    [categoryAxis, handleXAxisLabelClick],
+    [renderedXAxis, handleXAxisLabelClick],
   );
 
   const zrEventHandlers: EventHandlers = {
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 28a73cfe314..701c47d4aee 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -1248,7 +1248,7 @@ export default function transformProps(
     name: xAxisTitle,
     nameGap: convertInteger(xAxisTitleMargin),
     nameLocation: 'middle',
-    ...(xAxisType === AxisType.Category &&
+    ...((xAxisType === AxisType.Category || xAxisType === AxisType.Time) &&
       groupBy.length === 0 && {
         triggerEvent: true,
       }),
@@ -1600,6 +1600,7 @@ export default function transformProps(
       label: xAxisLabel,
       type: xAxisType,
     },
+    resolvedTimeGrain,
     refs,
     coltypeMapping: dataTypes,
     onLegendScroll,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
index 6f06fb84715..03bb1e165ea 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts
@@ -122,5 +122,6 @@ export type TimeseriesChartTransformedProps =
         label: string;
         type: AxisType;
       };
+      resolvedTimeGrain?: TimeGranularity;
       onFocusedSeries: (series: string | null) => void;
     };
diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
index 776ba420e6b..bb91ffea970 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/EchartsTimeseries.test.tsx
@@ -16,11 +16,17 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { render } from '@testing-library/react';
-import { AxisType } from '@superset-ui/core';
+import { render, waitFor } from '@testing-library/react';
+import { AxisType, DTTM_ALIAS, TimeGranularity } from '@superset-ui/core';
 import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
+import { logging } from '@apache-superset/core/utils';
+import type { ECElementEvent } from 'echarts/types/src/util/types';
 import EchartsTimeseries from '../../src/Timeseries/EchartsTimeseries';
-import { TimeseriesChartTransformedProps } from '../../src/Timeseries/types';
+import {
+  OrientationType,
+  TimeseriesChartTransformedProps,
+} from '../../src/Timeseries/types';
+import type { EchartsProps } from '../../src/types';
 
 // Percent-change draggable baseline: this is the one piece of the ECharts
 // rebuilds with zero prior test coverage despite six separate production
@@ -40,12 +46,14 @@ let mockChart: {
   convertFromPixel: jest.Mock;
   getModel: jest.Mock;
 };
+const mockEchart = jest.fn();
 
 jest.mock('../../src/components/Echart', () => {
   const { forwardRef, useImperativeHandle } = jest.requireActual('react');
   return {
     __esModule: true,
-    default: forwardRef((_props: unknown, ref: unknown) => {
+    default: forwardRef((props: unknown, ref: unknown) => {
+      mockEchart(props);
       useImperativeHandle(ref, () => ({
         getEchartInstance: () => mockChart,
       }));
@@ -115,6 +123,17 @@ function renderTimeseries(
   );
 }
 
+function getLatestEchartProps() {
+  const lastCall = mockEchart.mock.calls.at(-1);
+  expect(lastCall).toBeDefined();
+  const [props] = lastCall as [EchartsProps];
+  return props;
+}
+
+function advanceClickTimer() {
+  jest.advanceTimersByTime(300);
+}
+
 // Pulls the graphic descriptor for the draggable baseline handle out of the
 // most recent setOption call, mirroring how ECharts itself would read it.
 function getBaselineGraphic() {
@@ -126,6 +145,7 @@ function getBaselineGraphic() {
 
 beforeEach(() => {
   jest.clearAllMocks();
+  mockEchart.mockReset();
   setupChartMock();
   jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
     cb(0);
@@ -134,6 +154,7 @@ beforeEach(() => {
 });
 
 afterEach(() => {
+  jest.useRealTimers();
   jest.restoreAllMocks();
 });
 
@@ -245,3 +266,400 @@ test('does not touch the chart instance when rebase is disabled', () => {
 
   expect(mockChart.setOption).not.toHaveBeenCalled();
 });
+
+test('emits TEMPORAL_RANGE cross-filter from time axis label click on day bucket', () => {
+  const setDataMask = jest.fn();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: [],
+    resolvedTimeGrain: TimeGranularity.DAY,
+    formData: {
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.DAY,
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+    ({ query }) => query === 'xAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: '2021-01-01',
+  } as unknown as ECElementEvent);
+
+  expect(setDataMask.mock.calls[0][0]).toEqual({
+    extraFormData: {
+      filters: [
+        {
+          col: 'ds',
+          op: 'TEMPORAL_RANGE',
+          val: '2021-01-01T00:00:00 : 2021-01-02T00:00:00',
+        },
+      ],
+    },
+    filterState: {
+      label: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
+      value: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
+      selectedValues: ['2021-01-01T00:00:00 : 2021-01-02T00:00:00'],
+    },
+  });
+});
+
+test('emits upper-exclusive TEMPORAL_RANGE from time point click on month bucket', () => {
+  jest.useFakeTimers();
+  const setDataMask = jest.fn();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: [],
+    resolvedTimeGrain: TimeGranularity.MONTH,
+    formData: {
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.MONTH,
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  getLatestEchartProps().eventHandlers?.click?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+  advanceClickTimer();
+
+  expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('uses resolved time grain for temporal point-click cross-filter', () => {
+  jest.useFakeTimers();
+  const setDataMask = jest.fn();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: [],
+    resolvedTimeGrain: TimeGranularity.MONTH,
+    formData: {
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.DAY,
+      extraFormData: {
+        time_grain_sqla: TimeGranularity.MONTH,
+      },
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  getLatestEchartProps().eventHandlers?.click?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+  advanceClickTimer();
+
+  expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('emits TEMPORAL_RANGE from string-typed time point click value', () => {
+  jest.useFakeTimers();
+  const setDataMask = jest.fn();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: [],
+    resolvedTimeGrain: TimeGranularity.MONTH,
+    formData: {
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.MONTH,
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  getLatestEchartProps().eventHandlers?.click?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: ['2021-01-01T00:00:00Z', 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+  advanceClickTimer();
+
+  expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('emits TEMPORAL_RANGE from horizontal time point click using timestamp, not metric', () => {
+  jest.useFakeTimers();
+  const setDataMask = jest.fn();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: [],
+    resolvedTimeGrain: TimeGranularity.MONTH,
+    formData: {
+      orientation: OrientationType.Horizontal,
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.MONTH,
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  getLatestEchartProps().eventHandlers?.click?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [129, Date.UTC(2021, 0, 1)],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+  advanceClickTimer();
+
+  expect(setDataMask.mock.calls[0][0].extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
+
+test('clears temporal X-axis cross-filter when clicking selected bucket again', () => {
+  jest.useFakeTimers();
+  const setDataMask = jest.fn();
+  const selectedRange = '2021-01-01T00:00:00 : 2021-02-01T00:00:00';
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: [],
+    selectedValues: { 0: selectedRange },
+    resolvedTimeGrain: TimeGranularity.MONTH,
+    formData: {
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.MONTH,
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  getLatestEchartProps().eventHandlers?.click?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+  advanceClickTimer();
+
+  expect(setDataMask.mock.calls[0][0]).toEqual({
+    extraFormData: {
+      filters: [],
+    },
+    filterState: {
+      label: undefined,
+      value: null,
+      selectedValues: null,
+    },
+  });
+});
+
+test('emits empty temporal X-axis data mask when filter grain is missing', () => {
+  jest.useFakeTimers();
+  const setDataMask = jest.fn();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: [],
+    formData: {
+      granularitySqla: 'ds',
+      timeGrainSqla: undefined,
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  getLatestEchartProps().eventHandlers?.click?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    dataIndex: 0,
+  });
+  advanceClickTimer();
+
+  expect(setDataMask.mock.calls[0][0]).toEqual({
+    extraFormData: {
+      filters: [],
+    },
+    filterState: {
+      label: undefined,
+      value: null,
+      selectedValues: null,
+    },
+  });
+});
+
+test('warns and skips temporal cross-filter when string value cannot be parsed', () => {
+  jest.useFakeTimers();
+  const setDataMask = jest.fn();
+  const warn = jest.spyOn(logging, 'warn').mockImplementation();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: [],
+    resolvedTimeGrain: TimeGranularity.MONTH,
+    formData: {
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.MONTH,
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  getLatestEchartProps().eventHandlers?.click?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: ['not-a-date', 100],
+    name: 'not-a-date',
+    dataIndex: 0,
+  });
+  advanceClickTimer();
+
+  expect(setDataMask).not.toHaveBeenCalled();
+  expect(warn).toHaveBeenCalledWith(
+    'Unable to parse time axis value for cross-filtering',
+    'not-a-date',
+  );
+});
+
+test('does not emit temporal X-axis label cross-filter when dimensions are set', () => {
+  const setDataMask = jest.fn();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    setDataMask,
+    groupby: ['country'],
+    formData: {
+      groupby: ['country'],
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.MONTH,
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  const labelClickHandler = getLatestEchartProps().queryEventHandlers?.find(
+    ({ query }) => query === 'xAxis',
+  )?.handler;
+  expect(labelClickHandler).toBeDefined();
+  labelClickHandler?.({
+    targetType: 'axisLabel',
+    value: '2021-01-01',
+  } as unknown as ECElementEvent);
+
+  expect(setDataMask).not.toHaveBeenCalled();
+});
+
+test('context menu cross-filter is available for a temporal bar point', async () => {
+  const onContextMenu = jest.fn();
+
+  renderTimeseries({
+    emitCrossFilters: true,
+    onContextMenu,
+    groupby: [],
+    resolvedTimeGrain: TimeGranularity.MONTH,
+    formData: {
+      granularitySqla: 'ds',
+      timeGrainSqla: TimeGranularity.DAY,
+      extraFormData: {
+        time_grain_sqla: TimeGranularity.MONTH,
+      },
+      vizType: 'echarts_timeseries_line',
+    } as any,
+    xAxis: {
+      label: DTTM_ALIAS,
+      type: AxisType.Time,
+    },
+  });
+
+  await getLatestEchartProps().eventHandlers?.contextmenu?.({
+    componentType: 'series',
+    seriesName: 'Sales',
+    data: [Date.UTC(2021, 0, 1), 100],
+    name: '2021-01-01',
+    event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
+  });
+
+  await waitFor(() => {
+    expect(onContextMenu).toHaveBeenCalled();
+  });
+
+  const { crossFilter } = onContextMenu.mock.calls[0][2];
+  expect(crossFilter.dataMask.extraFormData.filters).toEqual([
+    {
+      col: 'ds',
+      op: 'TEMPORAL_RANGE',
+      val: '2021-01-01T00:00:00 : 2021-02-01T00:00:00',
+    },
+  ]);
+});
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 8065781b34f..8c3b705d7b0 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
@@ -2083,6 +2083,39 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
   expect(xAxis.triggerEvent).toBe(true);
 });
 
+test('temporal x-axis enables trigger events when no dimensions are set', () => {
+  const ts1 = 1745784000000;
+  const ts2 = 1745870400000;
+  const chartProps = createTestChartProps({
+    formData: {
+      metrics: ['metric'],
+      granularity_sqla: 'ds',
+      x_axis: '__timestamp',
+    },
+    queriesData: [
+      createTestQueryData(
+        [
+          { __timestamp: ts1, metric: 10 },
+          { __timestamp: ts2, metric: 20 },
+        ],
+        {
+          colnames: ['__timestamp', 'metric'],
+          coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+        },
+      ),
+    ],
+  });
+
+  const { echartOptions } = transformProps(chartProps);
+  const xAxis = echartOptions.xAxis as {
+    triggerEvent?: boolean;
+    type: string;
+  };
+
+  expect(xAxis.type).toBe(AxisType.Time);
+  expect(xAxis.triggerEvent).toBe(true);
+});
+
 test('temporal x coltype forced categorical yields a Category axis with date labels', () => {
   // Issue #28204: with a temporal x-axis (e.g. weekly grain) the default Time
   // scale places ticks at "nice" intervals that don't line up with the buckets.
@@ -2330,6 +2363,7 @@ test('tooltip time grain wiring: dashboard-level extraFormData time grain overri
   });
 
   const transformedProps = transformProps(chartProps);
+  expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.MONTH);
   const tooltipFormatter = (
     transformedProps.echartOptions as unknown as TooltipFormatterOptions
   ).tooltip.formatter;
@@ -2363,6 +2397,7 @@ test('tooltip time grain wiring: chart-level time grain drives the tooltip when
   });
 
   const transformedProps = transformProps(chartProps);
+  expect(transformedProps.resolvedTimeGrain).toBe(TimeGranularity.YEAR);
   const tooltipFormatter = (
     transformedProps.echartOptions as unknown as TooltipFormatterOptions
   ).tooltip.formatter;