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

aminghadersohi 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 be40309811d fix: filter Sunburst temporal segments by bucket and 
invert Hours offset in grained filters (#45598)
be40309811d is described below

commit be40309811d8836ff50c4f3f5f713553b6924d51
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Thu Oct 22 19:07:19 2026 -0400

    fix: filter Sunburst temporal segments by bucket and invert Hours offset in 
grained filters (#45598)
---
 docs/docs/using-superset/exploring-data.mdx        |  21 +
 .../src/Sunburst/EchartsSunburst.tsx               |  99 +++--
 .../src/Sunburst/transformProps.ts                 |  36 +-
 .../plugin-chart-echarts/src/Sunburst/types.ts     |   4 +
 .../plugin-chart-echarts/src/Sunburst/utils.ts     | 190 ++++++++
 .../src/utils/xAxisDrillByFilter.ts                |  37 +-
 .../test/Sunburst/EchartsSunburst.test.tsx         | 474 ++++++++++++++++++++
 .../test/Sunburst/transformProps.test.ts           | 149 ++++++-
 .../test/Sunburst/utils.test.ts                    |  52 +++
 .../test/Timeseries/EchartsTimeseries.test.tsx     |  40 ++
 .../Chart/DrillBy/DrillByTemporalFilters.test.tsx  | 256 +++++++++++
 superset/models/helpers.py                         |  91 +++-
 .../models/test_hours_offset_bound_truncation.py   | 480 ++++++++++++++++++++-
 13 files changed, 1860 insertions(+), 69 deletions(-)

diff --git a/docs/docs/using-superset/exploring-data.mdx 
b/docs/docs/using-superset/exploring-data.mdx
index 90a59d87c1a..d538b67e5ac 100644
--- a/docs/docs/using-superset/exploring-data.mdx
+++ b/docs/docs/using-superset/exploring-data.mdx
@@ -489,6 +489,27 @@ The **Custom** time range picker accepts natural language 
expressions alongside
 
 These expressions are evaluated at query time, so saved charts always display 
data relative to the current date.
 
+### Sunburst cross-filtering
+
+Sunburst cross-filters use the underlying hierarchy values, not the formatted
+segment labels. For time-grained temporal levels, selecting a segment filters 
the
+entire bucket. Adaptive date formatting includes the year in month and quarter
+labels to distinguish buckets across years; other grains keep their compact 
labels.
+For supported bucket ranges, the selection replaces the target chart's Time 
Range
+and also applies to its other temporal range filters, even on another time 
column.
+Drill to detail matches the selected bucket, including a dataset Hours offset, 
unless
+the chart also has a Time Range filter on the grained Time Column. Drill by 
matches the
+bucket only when the chart has no Time Range filter. Otherwise the query 
rebuilds its
+time bounds from the chart or dashboard Time Range, so the drill returns every 
row in
+that range. An engine-specific grain without a fixed length matches only the 
start of
+the bucket.
+
+On engines without SQL temporal shifts, grained comparisons (Drill to detail, 
grained
+`IN` / `NOT IN` cross-filters and other bucket comparisons) use the displayed 
bucket's
+original, unshifted grouping boundaries, undoing the display-only Hours offset 
for each
+selected value. A temporal level without a time grain is compared with the 
stored
+timestamp, so with an Hours offset its Drill to detail does not match the 
displayed value.
+
 ### Downloading Drill to Detail and Drill By Results
 
 The **Drill to detail** and **Drill by** modals, available from a chart's 
context menu, show the row-level
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
 
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
index 8ba09fd5d8f..7420e32ebba 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx
@@ -20,13 +20,17 @@ import { useCallback } from 'react';
 import {
   BinaryQueryObjectFilterClause,
   getColumnLabel,
-  getNumberFormatter,
-  getTimeFormatter,
+  DataRecordValue,
 } from '@superset-ui/core';
 import { SunburstTransformedProps } from './types';
 import Echart from '../components/Echart';
 import { EventHandlers, TreePathInfo } from '../types';
-import { formatSeriesName } from '../utils/series';
+import {
+  getSunburstDetailFilter,
+  getSunburstDrillByFilter,
+  getSunburstFilter,
+  getSunburstSelectionKey,
+} from './utils';
 
 export const extractTreePathInfo = (treePathInfo: TreePathInfo[] | undefined) 
=>
   (treePathInfo ?? [])
@@ -46,18 +50,25 @@ export default function EchartsSunburst(props: 
SunburstTransformedProps) {
     emitCrossFilters,
     coltypeMapping,
   } = props;
-  const { columns } = formData;
+  const { columns = [] } = formData;
 
   const getCrossFilterDataMask = useCallback(
-    (treePathInfo: TreePathInfo[]) => {
+    (treePathInfo: TreePathInfo[], records: DataRecordValue[]) => {
       const treePath = extractTreePathInfo(treePathInfo);
-      const joinedTreePath = treePath.join(',');
+      const joinedTreePath = getSunburstSelectionKey(
+        records,
+        treePath,
+        columns,
+        coltypeMapping,
+      );
+      const index = records.length - 1;
       const value = treePath[treePath.length - 1];
 
       const isCurrentValueSelected =
-        Object.values(selectedValues).includes(joinedTreePath);
+        Object.values(selectedValues).includes(joinedTreePath) ||
+        Object.values(selectedValues).includes(treePath.join(','));
 
-      if (!columns?.length || isCurrentValueSelected) {
+      if (!columns?.length || !records.length || isCurrentValueSelected) {
         return {
           dataMask: {
             extraFormData: {
@@ -72,16 +83,21 @@ export default function EchartsSunburst(props: 
SunburstTransformedProps) {
         };
       }
 
+      const filter = getSunburstFilter(
+        columns[index],
+        records[index],
+        formData,
+        coltypeMapping?.[getColumnLabel(columns[index])],
+      );
+
       return {
         dataMask: {
           extraFormData: {
-            filters: [
-              {
-                col: columns[treePath.length - 1],
-                op: '==' as const,
-                val: value,
-              },
-            ],
+            filters: [filter],
+            // Target queries rewrite temporal filter bounds from time_range.
+            ...(filter.op === 'TEMPORAL_RANGE' && {
+              time_range: String(filter.val),
+            }),
           },
           filterState: {
             value,
@@ -91,24 +107,24 @@ export default function EchartsSunburst(props: 
SunburstTransformedProps) {
         isCurrentValueSelected,
       };
     },
-    [columns, selectedValues],
+    [columns, selectedValues, coltypeMapping, formData],
   );
 
   const handleChange = useCallback(
-    (treePathInfo: TreePathInfo[]) => {
+    (treePathInfo: TreePathInfo[], records: DataRecordValue[]) => {
       if (!emitCrossFilters || !columns?.length) {
         return;
       }
 
-      setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
+      setDataMask(getCrossFilterDataMask(treePathInfo, records).dataMask);
     },
     [emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
   );
 
   const eventHandlers: EventHandlers = {
     click: props => {
-      const { treePathInfo } = props;
-      handleChange(treePathInfo);
+      const { treePathInfo, data } = props;
+      handleChange(treePathInfo, data.records);
     },
     contextmenu: async eventParams => {
       if (onContextMenu) {
@@ -120,31 +136,34 @@ export default function EchartsSunburst(props: 
SunburstTransformedProps) {
         const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
         const drillByFilters: BinaryQueryObjectFilterClause[] = [];
         if (columns?.length) {
-          treePath.forEach((path, i) =>
-            drillToDetailFilters.push({
-              col: columns[i],
-              op: '==',
-              val: records[i],
-              formattedVal: path,
-            }),
-          );
-          const val = treePath[treePath.length - 1];
-          drillByFilters.push({
-            col: columns[treePath.length - 1],
-            op: '==',
-            val,
-            formattedVal: formatSeriesName(val, {
-              timeFormatter: getTimeFormatter(formData.dateFormat),
-              numberFormatter: getNumberFormatter(formData.numberFormat),
-              coltype:
-                coltypeMapping?.[getColumnLabel(columns[treePath.length - 1])],
-            }),
+          records.forEach((record: DataRecordValue, i: number) => {
+            const coltype = coltypeMapping?.[getColumnLabel(columns[i])];
+            drillToDetailFilters.push(
+              getSunburstDetailFilter(
+                columns[i],
+                record,
+                formData,
+                coltype,
+                treePath[i],
+              ),
+            );
+            if (i === records.length - 1) {
+              drillByFilters.push(
+                getSunburstDrillByFilter(
+                  columns[i],
+                  record,
+                  formData,
+                  coltype,
+                  treePath[i],
+                ),
+              );
+            }
           });
         }
         onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
           drillToDetail: drillToDetailFilters,
           crossFilter: columns?.length
-            ? getCrossFilterDataMask(treePathInfo)
+            ? getCrossFilterDataMask(treePathInfo, records)
             : undefined,
           drillBy: { filters: drillByFilters, groupbyFieldName: 'columns' },
         });
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts 
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
index f04d67857cc..90c93c3c45b 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts
@@ -25,6 +25,9 @@ import {
   getNumberFormatter,
   getSequentialSchemeRegistry,
   getTimeFormatter,
+  getTimeFormatterForGranularity,
+  SMART_DATE_ID,
+  TimeGranularity,
   getValueFormatter,
   NumberFormats,
   tooltipHtml,
@@ -44,6 +47,11 @@ import {
   SunburstTransformedProps,
 } from './types';
 import { getDefaultTooltip } from '../utils/tooltip';
+import {
+  getSunburstTimeGrain,
+  getSunburstSelectionKey,
+  isSunburstPathSelected,
+} from './utils';
 
 export function getLinearDomain(
   treeData: TreeNode[],
@@ -298,9 +306,22 @@ export default function transformProps(
     treeNodes.map(treeNode => {
       const { name: nodeName, value, secondaryValue, groupBy } = treeNode;
       const records = [...(pathRecords || []), nodeName];
+      const timeGrain = getSunburstTimeGrain(
+        formData,
+        columns[records.length - 1],
+      );
       let name = formatSeriesName(nodeName, {
         numberFormatter,
-        timeFormatter: getTimeFormatter(dateFormat),
+        timeFormatter:
+          dateFormat === SMART_DATE_ID &&
+          timeGrain &&
+          [
+            TimeGranularity.MONTH,
+            TimeGranularity.QUARTER,
+            TimeGranularity.YEAR,
+          ].some(grain => grain === timeGrain)
+            ? getTimeFormatterForGranularity(timeGrain)
+            : getTimeFormatter(dateFormat),
         ...(coltypeMapping[groupBy] && {
           coltype: coltypeMapping[groupBy],
         }),
@@ -323,7 +344,18 @@ export default function transformProps(
         name = newPath.join(',');
       }
       columnsLabelMap.set(name, newPath);
-      if (filterState.selectedValues?.[0]?.includes(name) === false) {
+      const selectionKey = getSunburstSelectionKey(
+        records,
+        newPath,
+        columns,
+        coltypeMapping,
+      );
+      const selectedValue = filterState.selectedValues?.[0];
+      if (
+        selectedValue != null &&
+        !isSunburstPathSelected(selectedValue, selectionKey) &&
+        !isSunburstPathSelected(selectedValue, newPath.join(','))
+      ) {
         item = {
           ...item,
           itemStyle: {
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts 
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
index ef278abd6e2..c3bcde3f10a 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/types.ts
@@ -24,6 +24,7 @@ import {
   QueryFormColumn,
   QueryFormData,
   QueryFormMetric,
+  TimeGranularity,
 } from '@superset-ui/core';
 import type { SunburstSeriesNodeItemOption } from 
'echarts/types/src/chart/sunburst/SunburstSeries';
 import {
@@ -38,6 +39,9 @@ export type EchartsSunburstFormData = QueryFormData & {
   secondaryMetric?: QueryFormMetric;
   colorScheme?: string;
   linearColorScheme?: string;
+  timeGrainSqla?: TimeGranularity;
+  temporalColumnsLookup?: Record<string, boolean>;
+  granularitySqla?: string;
 };
 
 export enum EchartsSunburstLabelType {
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/utils.ts 
b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/utils.ts
new file mode 100644
index 00000000000..923f5052269
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/utils.ts
@@ -0,0 +1,190 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+  BinaryQueryObjectFilterClause,
+  DataRecordValue,
+  getColumnLabel,
+  isPhysicalColumn,
+  QueryFormColumn,
+  TimeGranularity,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
+import { EchartsSunburstFormData } from './types';
+import {
+  formatNaiveDateTime,
+  getTemporalXAxisDrillByFilter,
+  parseTemporalValue,
+} from '../utils/xAxisDrillByFilter';
+
+/**
+ * Return the effective grain only for a grained physical hierarchy level. A
+ * dashboard Time Column filter supplies the grained column through
+ * ``extraFormData.granularity_sqla``, which the query uses as its granularity.
+ */
+export function getSunburstTimeGrain(
+  formData: EchartsSunburstFormData,
+  column: QueryFormColumn,
+): TimeGranularity | undefined {
+  if (!isPhysicalColumn(column)) {
+    return undefined;
+  }
+  const effectiveColumn =
+    formData.extraFormData?.granularity_sqla ?? formData.granularitySqla;
+  const temporalLevels = (formData.columns ?? []).filter(
+    level => isPhysicalColumn(level) && 
formData.temporalColumnsLookup?.[level],
+  );
+  const grainedColumn =
+    effectiveColumn ||
+    (temporalLevels.length === 1 ? temporalLevels[0] : undefined);
+  return grainedColumn === column
+    ? (formData.extraFormData?.time_grain_sqla ?? formData.timeGrainSqla)
+    : undefined;
+}
+
+/**
+ * Whether a segment is the selected segment or one of its ancestors. Keys are
+ * compared level by level, so a year label never matches digits inside an
+ * epoch key.
+ */
+export function isSunburstPathSelected(
+  selectedValue: unknown,
+  key: string,
+): boolean {
+  const selected = String(selectedValue);
+  return selected === key || selected.startsWith(`${key},`);
+}
+
+/** Keep temporal selection identity independent of the display format. */
+export function getSunburstSelectionKey(
+  records: DataRecordValue[],
+  labels: string[],
+  columns: QueryFormColumn[],
+  coltypeMapping: Record<string, GenericDataType> = {},
+): string {
+  return records
+    .map((value, index) =>
+      coltypeMapping[getColumnLabel(columns[index])] ===
+      GenericDataType.Temporal
+        ? String(value instanceof Date ? value.getTime() : value)
+        : labels[index],
+    )
+    .join(',');
+}
+
+/**
+ * Exact temporal value for filters the backend receives as written. A physical
+ * column gets epoch milliseconds, which the backend turns into an 
engine-native
+ * timestamp literal. An adhoc SQL expression has no native type to convert to,
+ * so it gets the naive ISO datetime instead of an integer.
+ */
+function getSunburstTemporalValue(
+  column: QueryFormColumn,
+  value: DataRecordValue,
+): DataRecordValue {
+  const date = parseTemporalValue(value);
+  if (!date) {
+    return value;
+  }
+  return isPhysicalColumn(column) ? date.getTime() : formatNaiveDateTime(date);
+}
+
+/** Build cross-filters from raw hierarchy records, preserving bucket bounds. 
*/
+export function getSunburstFilter(
+  column: QueryFormColumn,
+  value: DataRecordValue,
+  formData: EchartsSunburstFormData,
+  coltype: GenericDataType | undefined,
+  formattedVal?: string,
+): BinaryQueryObjectFilterClause {
+  if (coltype !== GenericDataType.Temporal) {
+    return { col: column, op: '==', val: value, formattedVal };
+  }
+  const grain = getSunburstTimeGrain(formData, column);
+  const temporalFilter = getTemporalXAxisDrillByFilter(
+    column,
+    value,
+    grain,
+    formattedVal,
+  );
+  if (!temporalFilter) {
+    return { col: column, op: '==', val: value, formattedVal };
+  }
+  if (temporalFilter.op === 'TEMPORAL_RANGE') {
+    return temporalFilter;
+  }
+  // Grains without frontend bucket arithmetic match on the grained column.
+  return {
+    col: column,
+    op: '==',
+    val: getSunburstTemporalValue(column, value),
+    formattedVal,
+    ...(grain && { grain }),
+  };
+}
+
+/**
+ * Drill to detail sends its filters to the backend unchanged, so it compares
+ * bucket labels on the grained column. Unlike TEMPORAL_RANGE, equality
+ * survives the query time_range override.
+ */
+export function getSunburstDetailFilter(
+  column: QueryFormColumn,
+  value: DataRecordValue,
+  formData: EchartsSunburstFormData,
+  coltype: GenericDataType | undefined,
+  formattedVal?: string,
+): BinaryQueryObjectFilterClause {
+  if (coltype !== GenericDataType.Temporal) {
+    return { col: column, op: '==', val: value, formattedVal };
+  }
+  const grain = getSunburstTimeGrain(formData, column);
+  return {
+    col: column,
+    op: '==',
+    val: getSunburstTemporalValue(column, value),
+    formattedVal,
+    ...(grain && { grain }),
+  };
+}
+
+/**
+ * Drill by converts its filters to adhoc filters, which keep only the column,
+ * operator and comparator, so a grain would be dropped. A known grain 
therefore
+ * selects its bucket with TEMPORAL_RANGE. As with Timeseries Drill by, a query
+ * time_range replaces that range. Other temporal values use the naive ISO
+ * datetime, which SQL expression filters compare without backend coercion.
+ */
+export function getSunburstDrillByFilter(
+  column: QueryFormColumn,
+  value: DataRecordValue,
+  formData: EchartsSunburstFormData,
+  coltype: GenericDataType | undefined,
+  formattedVal?: string,
+): BinaryQueryObjectFilterClause {
+  const temporalFilter =
+    coltype === GenericDataType.Temporal
+      ? getTemporalXAxisDrillByFilter(
+          column,
+          value,
+          getSunburstTimeGrain(formData, column),
+          formattedVal,
+        )
+      : undefined;
+  return temporalFilter ?? { col: column, op: '==', val: value, formattedVal };
+}
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter.ts
index 39b60655f5b..57419d196a7 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter.ts
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter.ts
@@ -115,6 +115,24 @@ const TIMEZONE_SUFFIX_RE = /(Z|[+-]\d{2}:?\d{2})$/;
 const parseAsUtc = (value: string): Date =>
   new Date(TIMEZONE_SUFFIX_RE.test(value) ? value : `${value}Z`);
 
+/**
+ * Convert a temporal record value (epoch milliseconds, naive UTC datetime
+ * string or Date) to a Date. Returns undefined for anything else.
+ */
+export const parseTemporalValue = (value: unknown): Date | undefined => {
+  let date: Date;
+  if (value instanceof Date) {
+    date = value;
+  } else if (typeof value === 'string') {
+    date = parseAsUtc(value);
+  } else if (typeof value === 'number') {
+    date = new Date(value);
+  } else {
+    return undefined;
+  }
+  return Number.isNaN(date.getTime()) ? undefined : date;
+};
+
 /**
  * Build a drill-by filter clause matching the clicked value on a temporal
  * x-axis. When a known time grain is active, the clause is a TEMPORAL_RANGE
@@ -127,23 +145,8 @@ export const getTemporalXAxisDrillByFilter = (
   grain?: TimeGranularity,
   formattedVal?: string,
 ): BinaryQueryObjectFilterClause | undefined => {
-  if (
-    !col ||
-    (typeof value !== 'number' &&
-      typeof value !== 'string' &&
-      !(value instanceof Date))
-  ) {
-    return undefined;
-  }
-  let bucketLabel: Date;
-  if (value instanceof Date) {
-    bucketLabel = value;
-  } else if (typeof value === 'string') {
-    bucketLabel = parseAsUtc(value);
-  } else {
-    bucketLabel = new Date(value);
-  }
-  if (Number.isNaN(bucketLabel.getTime())) {
+  const bucketLabel = parseTemporalValue(value);
+  if (!col || !bucketLabel) {
     return undefined;
   }
   const range = grain ? getTimeBucketRange(bucketLabel, grain) : undefined;
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/EchartsSunburst.test.tsx
 
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/EchartsSunburst.test.tsx
new file mode 100644
index 00000000000..79c1f4c9ca5
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/EchartsSunburst.test.tsx
@@ -0,0 +1,474 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render } from '@testing-library/react';
+import {
+  ChartProps,
+  QueryFormColumn,
+  TimeGranularity,
+  createSmartDateFormatter,
+  getTimeFormatterRegistry,
+  SMART_DATE_ID,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
+import { supersetTheme } from '@apache-superset/core/theme';
+import EchartsSunburst from '../../src/Sunburst/EchartsSunburst';
+import transformProps from '../../src/Sunburst/transformProps';
+import {
+  EchartsSunburstChartProps,
+  EchartsSunburstFormData,
+  NodeItemOption,
+} from '../../src/Sunburst/types';
+import { EchartsProps } from '../../src/types';
+
+// Match the app bootstrap's formatter registration.
+beforeAll(() => {
+  getTimeFormatterRegistry().registerValue(
+    SMART_DATE_ID,
+    createSmartDateFormatter(),
+  );
+});
+
+const mockEchart = jest.fn();
+jest.mock('../../src/components/Echart', () => (props: EchartsProps) => {
+  mockEchart(props);
+  return null;
+});
+
+function setup({
+  grain,
+  override,
+  raw = Date.UTC(2024, 9, 1),
+  selectedValues = [] as string[],
+  dateFormat = 'smart_date',
+  temporalFirst = false,
+  granularitySqla = 'order_date',
+  temporalColumnsLookup,
+  timeColumnOverride,
+  adhocTemporal = false,
+}: {
+  grain?: TimeGranularity;
+  override?: TimeGranularity;
+  raw?: number | string | Date | null;
+  selectedValues?: string[];
+  dateFormat?: string;
+  temporalFirst?: boolean;
+  granularitySqla?: string;
+  temporalColumnsLookup?: Record<string, boolean>;
+  timeColumnOverride?: string;
+  adhocTemporal?: boolean;
+} = {}) {
+  const setDataMask = jest.fn();
+  const onContextMenu = jest.fn();
+  const temporalColumn: QueryFormColumn = adhocTemporal
+    ? {
+        expressionType: 'SQL',
+        sqlExpression: "DATE_TRUNC('month', order_date)",
+        label: 'order_date',
+      }
+    : 'order_date';
+  const columns = temporalFirst
+    ? [temporalColumn, 'category']
+    : ['category', temporalColumn];
+  const props = transformProps(
+    new ChartProps<EchartsSunburstFormData>({
+      formData: {
+        datasource: '1__table',
+        viz_type: 'sunburst_v2',
+        groupby: [],
+        columns,
+        metric: 'count',
+        time_range: '2024-01-01 : 2025-01-01',
+        dateFormat,
+        timeGrainSqla: grain,
+        granularitySqla,
+        temporalColumnsLookup,
+        extraFormData: {
+          ...(override && { time_grain_sqla: override }),
+          ...(timeColumnOverride && { granularity_sqla: timeColumnOverride }),
+        },
+      },
+      width: 800,
+      height: 600,
+      theme: supersetTheme,
+      queriesData: [
+        {
+          colnames: ['category', 'order_date', 'count'],
+          coltypes: [
+            GenericDataType.String,
+            GenericDataType.Temporal,
+            GenericDataType.Numeric,
+          ],
+          data: [{ category: 'A', order_date: raw, count: 10 }],
+        },
+      ],
+      hooks: { setDataMask, onContextMenu },
+      filterState: { selectedValues },
+      emitCrossFilters: true,
+    }) as EchartsSunburstChartProps,
+  );
+  render(<EchartsSunburst {...props} />);
+  const { eventHandlers } = mockEchart.mock.lastCall[0] as EchartsProps;
+  const nodes = (
+    props.echartOptions as { series: { data: NodeItemOption[] }[] }
+  ).series[0].data;
+  const path = [nodes[0], nodes[0].children?.[0] as NodeItemOption];
+  const event = (depth = 2) => ({
+    data: path[depth - 1],
+    treePathInfo: [
+      { name: '', dataIndex: 0, value: 10 },
+      ...path
+        .slice(0, depth)
+        .map((node, i) => ({ name: node.name, dataIndex: i + 1, value: 10 })),
+    ],
+    event: { stop: jest.fn(), event: { clientX: 0, clientY: 0 } },
+  });
+  return {
+    setDataMask,
+    onContextMenu,
+    eventHandlers: eventHandlers!,
+    event,
+    path,
+  };
+}
+
+test('cross-filters a raw temporal segment with the timestamp rather than its 
label', () => {
+  const { eventHandlers, event, setDataMask } = setup({
+    raw: Date.UTC(2024, 9, 14, 12, 30),
+  });
+  eventHandlers.click(event());
+  expect(setDataMask).toHaveBeenCalledWith(
+    expect.objectContaining({
+      extraFormData: {
+        filters: [
+          { col: 'order_date', op: '==', val: Date.UTC(2024, 9, 14, 12, 30) },
+        ],
+      },
+    }),
+  );
+});
+
+test.each([
+  [TimeGranularity.MONTH, '2024-10-01T00:00:00 : 2024-11-01T00:00:00'],
+  [TimeGranularity.YEAR, '2024-01-01T00:00:00 : 2025-01-01T00:00:00'],
+])(
+  'cross-filters the entire %s bucket with upper-exclusive bounds',
+  (grain, range) => {
+    const raw =
+      grain === TimeGranularity.YEAR
+        ? Date.UTC(2024, 0, 1)
+        : Date.UTC(2024, 9, 1);
+    const { eventHandlers, event, setDataMask } = setup({ grain, raw });
+    eventHandlers.click(event());
+    expect(setDataMask).toHaveBeenCalledWith(
+      expect.objectContaining({
+        extraFormData: {
+          filters: [{ col: 'order_date', op: 'TEMPORAL_RANGE', val: range }],
+          time_range: range,
+        },
+      }),
+    );
+  },
+);
+
+test('uses the dashboard grain override for temporal cross-filters', () => {
+  const { eventHandlers, event, setDataMask } = setup({
+    grain: TimeGranularity.MONTH,
+    override: TimeGranularity.YEAR,
+    raw: '2024-01-01T00:00:00',
+  });
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData.time_range).toBe(
+    '2024-01-01T00:00:00 : 2025-01-01T00:00:00',
+  );
+});
+
+test('keeps categorical cross-filters and click-to-clear behavior', () => {
+  const first = setup({ grain: TimeGranularity.MONTH });
+  first.eventHandlers.click(first.event(1));
+  const mask = first.setDataMask.mock.lastCall[0];
+  expect(mask).toEqual({
+    extraFormData: { filters: [{ col: 'category', op: '==', val: 'A' }] },
+    filterState: { value: 'A', selectedValues: ['A'] },
+  });
+  const second = setup({ selectedValues: mask.filterState.selectedValues });
+  second.eventHandlers.click(second.event(1));
+  expect(second.setDataMask.mock.lastCall[0].extraFormData).toEqual({
+    filters: [],
+  });
+});
+
+test('drill-by sends an ungrained temporal value as a naive ISO datetime', () 
=> {
+  const { eventHandlers, event, onContextMenu, path } = setup();
+  eventHandlers.contextmenu(event());
+  expect(onContextMenu.mock.lastCall[2].drillBy.filters).toEqual([
+    {
+      col: 'order_date',
+      op: '==',
+      val: '2024-10-01T00:00:00',
+      formattedVal: path[1].name,
+    },
+  ]);
+});
+
+test('drill by selects a grained bucket with its TEMPORAL_RANGE', () => {
+  // Drill by keeps only column, operator and comparator, so a grain is lost.
+  const { eventHandlers, event, onContextMenu, path } = setup({
+    grain: TimeGranularity.MONTH,
+  });
+  eventHandlers.contextmenu(event());
+  const context = onContextMenu.mock.lastCall[2];
+  expect(context.drillBy.filters).toEqual([
+    {
+      col: 'order_date',
+      op: 'TEMPORAL_RANGE',
+      val: '2024-10-01T00:00:00 : 2024-11-01T00:00:00',
+      formattedVal: path[1].name,
+    },
+  ]);
+  expect(context.crossFilter.dataMask.extraFormData.filters[0].op).toBe(
+    'TEMPORAL_RANGE',
+  );
+});
+
+test('drill to detail matches a grained bucket by its label and grain', () => {
+  // Samples queries replace TEMPORAL_RANGE values with the chart's time_range,
+  // so a range here would return every row of the dashboard time range.
+  const { eventHandlers, event, onContextMenu, path } = setup({
+    grain: TimeGranularity.MONTH,
+  });
+  eventHandlers.contextmenu(event());
+  expect(onContextMenu.mock.lastCall[2].drillToDetail[1]).toEqual({
+    col: 'order_date',
+    op: '==',
+    val: Date.UTC(2024, 9, 1),
+    grain: TimeGranularity.MONTH,
+    formattedVal: path[1].name,
+  });
+});
+
+test('temporal segments with identical custom labels do not toggle each other 
off', () => {
+  const first = setup({ raw: Date.UTC(2023, 9, 1), dateFormat: '%B' });
+  first.eventHandlers.click(first.event());
+  const second = setup({
+    raw: Date.UTC(2024, 9, 1),
+    dateFormat: '%B',
+    selectedValues:
+      first.setDataMask.mock.lastCall[0].filterState.selectedValues,
+  });
+  second.eventHandlers.click(second.event());
+  expect(
+    second.setDataMask.mock.lastCall[0].extraFormData.filters,
+  ).toHaveLength(1);
+});
+
+test('cross-filters a temporal level before a categorical level', () => {
+  const { eventHandlers, event, setDataMask } = setup({
+    grain: TimeGranularity.MONTH,
+    temporalFirst: true,
+  });
+  eventHandlers.click(event(1));
+  expect(setDataMask.mock.lastCall[0].extraFormData.filters[0]).toMatchObject({
+    col: 'order_date',
+    op: 'TEMPORAL_RANGE',
+  });
+});
+
+test('uses the temporal hierarchy lookup without a legacy granularity 
subject', () => {
+  const { eventHandlers, event, setDataMask } = setup({
+    grain: TimeGranularity.MONTH,
+    granularitySqla: '',
+    temporalColumnsLookup: { order_date: true },
+  });
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData.filters[0].op).toBe(
+    'TEMPORAL_RANGE',
+  );
+});
+
+test("does not apply another temporal column's grain to a raw level", () => {
+  const { eventHandlers, event, setDataMask } = setup({
+    grain: TimeGranularity.MONTH,
+    granularitySqla: 'other_date',
+  });
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+    filters: [{ col: 'order_date', op: '==', val: Date.UTC(2024, 9, 1) }],
+  });
+});
+
+test('clears the temporal range override when clicking the selected bucket', 
() => {
+  const first = setup({ grain: TimeGranularity.MONTH });
+  first.eventHandlers.click(first.event());
+  const second = setup({
+    grain: TimeGranularity.MONTH,
+    selectedValues:
+      first.setDataMask.mock.lastCall[0].filterState.selectedValues,
+  });
+  second.eventHandlers.click(second.event());
+  expect(second.setDataMask.mock.lastCall[0]).toEqual({
+    extraFormData: { filters: [] },
+    filterState: { value: null, selectedValues: [] },
+  });
+});
+
+test('keeps a null temporal value rather than filtering the null display 
label', () => {
+  const { eventHandlers, event, setDataMask } = setup({
+    raw: null,
+    grain: TimeGranularity.MONTH,
+  });
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+    filters: [{ col: 'order_date', op: '==', val: null }],
+  });
+});
+
+test('keeps categorical child filters unchanged beneath a temporal ancestor', 
() => {
+  const { eventHandlers, event, setDataMask } = setup({
+    grain: TimeGranularity.MONTH,
+    temporalFirst: true,
+  });
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+    filters: [{ col: 'category', op: '==', val: 'A' }],
+  });
+});
+
+test('grains the level chosen by a dashboard Time Column filter', () => {
+  const { eventHandlers, event, setDataMask, path } = setup({
+    granularitySqla: '',
+    override: TimeGranularity.MONTH,
+    timeColumnOverride: 'order_date',
+  });
+  expect(path[1].name).toBe('Oct 2024');
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+    filters: [
+      {
+        col: 'order_date',
+        op: 'TEMPORAL_RANGE',
+        val: '2024-10-01T00:00:00 : 2024-11-01T00:00:00',
+      },
+    ],
+    time_range: '2024-10-01T00:00:00 : 2024-11-01T00:00:00',
+  });
+});
+
+test('uses only the effective dashboard time column, not the saved one', () => 
{
+  const { eventHandlers, event, setDataMask, path } = setup({
+    grain: TimeGranularity.MONTH,
+    timeColumnOverride: 'other_date',
+  });
+  expect(path[1].name).toBe('October');
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+    filters: [{ col: 'order_date', op: '==', val: Date.UTC(2024, 9, 1) }],
+  });
+});
+
+test.each(['PT6H', 'PT5S', 'PT30S', 'PT0.5H', 'P0.25Y', 'custom_grain'])(
+  'preserves the engine grain %s for cross-filter and drill equality',
+  grain => {
+    const { eventHandlers, event, setDataMask, onContextMenu, path } = setup({
+      grain: grain as TimeGranularity,
+      raw: Date.UTC(2024, 9, 7, 6),
+    });
+    expect(path[1].name).toBe('06 AM');
+    eventHandlers.click(event());
+    expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+      filters: [
+        { col: 'order_date', op: '==', val: Date.UTC(2024, 9, 7, 6), grain },
+      ],
+    });
+    eventHandlers.contextmenu(event());
+    expect(onContextMenu.mock.lastCall[2].drillToDetail[1]).toEqual({
+      col: 'order_date',
+      op: '==',
+      val: Date.UTC(2024, 9, 7, 6),
+      grain,
+      formattedVal: path[1].name,
+    });
+    // Drill by cannot carry a grain, so it matches the bucket start instant.
+    expect(onContextMenu.mock.lastCall[2].drillBy.filters).toEqual([
+      {
+        col: 'order_date',
+        op: '==',
+        val: '2024-10-07T06:00:00',
+        formattedVal: path[1].name,
+      },
+    ]);
+  },
+);
+
+test('Date selection keys use epoch milliseconds', () => {
+  const raw = new Date(Date.UTC(2024, 9, 1));
+  const { eventHandlers, event, setDataMask } = setup({ raw });
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].filterState.selectedValues).toEqual([
+    `A,${raw.getTime()}`,
+  ]);
+});
+
+test('legacy label-based temporal selections remain highlighted and click to 
clear', () => {
+  const { eventHandlers, event, setDataMask, path } = setup({
+    selectedValues: ['A,October'],
+  });
+  expect(path[1].itemStyle?.opacity).not.toBe(0.3);
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({ filters: [] });
+});
+
+test('ungrained drill to detail keeps the epoch value for native engine 
coercion', () => {
+  const raw = Date.UTC(2003, 10, 14);
+  const { eventHandlers, event, onContextMenu, path } = setup({ raw });
+  eventHandlers.contextmenu(event());
+  expect(onContextMenu.mock.lastCall[2].drillToDetail[1]).toEqual({
+    col: 'order_date',
+    op: '==',
+    val: raw,
+    formattedVal: path[1].name,
+  });
+});
+
+test('adhoc temporal levels compare a naive ISO datetime, not an epoch 
integer', () => {
+  // An adhoc SQL expression has no native type for the backend to convert an
+  // epoch into, so `(expr) = 1727740800000` would fail on typed engines.
+  const { eventHandlers, event, onContextMenu, setDataMask, path } = setup({
+    adhocTemporal: true,
+    grain: TimeGranularity.MONTH,
+  });
+  const col = {
+    expressionType: 'SQL',
+    sqlExpression: "DATE_TRUNC('month', order_date)",
+    label: 'order_date',
+  };
+  eventHandlers.click(event());
+  expect(setDataMask.mock.lastCall[0].extraFormData).toEqual({
+    filters: [{ col, op: '==', val: '2024-10-01T00:00:00' }],
+  });
+  eventHandlers.contextmenu(event());
+  const context = onContextMenu.mock.lastCall[2];
+  for (const filter of [context.drillToDetail[1], context.drillBy.filters[0]]) 
{
+    expect(filter).toEqual({
+      col,
+      op: '==',
+      val: '2024-10-01T00:00:00',
+      formattedVal: path[1].name,
+    });
+  }
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/transformProps.test.ts
 
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/transformProps.test.ts
index 6980bc5608a..8d06d721b79 100644
--- 
a/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/transformProps.test.ts
+++ 
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/transformProps.test.ts
@@ -16,15 +16,33 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { ChartProps } from '@superset-ui/core';
+import {
+  ChartProps,
+  TimeGranularity,
+  createSmartDateFormatter,
+  getTimeFormatterRegistry,
+  SMART_DATE_ID,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
 import { supersetTheme } from '@apache-superset/core/theme';
-import { EchartsSunburstChartProps } from '../../src/Sunburst/types';
+import {
+  EchartsSunburstChartProps,
+  EchartsSunburstFormData,
+} from '../../src/Sunburst/types';
 import transformProps from '../../src/Sunburst/transformProps';
 
 type SunburstSeries = {
   label?: Record<string, unknown>;
   data: { value: number }[];
 };
+// Match the app bootstrap's formatter registration.
+beforeAll(() => {
+  getTimeFormatterRegistry().registerValue(
+    SMART_DATE_ID,
+    createSmartDateFormatter(),
+  );
+});
+
 const firstSeries = (echartOptions: unknown) =>
   (echartOptions as { series: SunburstSeries[] }).series[0];
 
@@ -103,3 +121,130 @@ test('keeps null values when showNullValues is true', () 
=> {
 test('removes null values when showNullValues is false', () => {
   expect(seriesValues(nullValueProps(false))).toEqual([10, 20]);
 });
+
+test.each([
+  TimeGranularity.MONTH,
+  TimeGranularity.QUARTER,
+  TimeGranularity.YEAR,
+])('smart_date labels distinguish %s buckets across years', grain => {
+  const month = grain === TimeGranularity.YEAR ? 0 : 9;
+  const props = new ChartProps<EchartsSunburstFormData>({
+    formData: {
+      ...formData,
+      viz_type: 'sunburst_v2',
+      columns: ['order_date'],
+      dateFormat: 'smart_date',
+      timeGrainSqla: grain,
+      granularitySqla: 'order_date',
+    },
+    width: 800,
+    height: 600,
+    theme: supersetTheme,
+    queriesData: [
+      {
+        colnames: ['order_date', 'sum__value'],
+        coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+        data: [
+          { order_date: Date.UTC(2023, month, 1), sum__value: 10 },
+          { order_date: Date.UTC(2024, month, 1), sum__value: 20 },
+        ],
+      },
+    ],
+  });
+  const result = transformProps(props as EchartsSunburstChartProps);
+  const nodes = (
+    result.echartOptions as {
+      series: { data: { name: string; records: number[] }[] }[];
+    }
+  ).series[0].data;
+  expect(nodes[0].name).toContain('2023');
+  expect(nodes[1].name).toContain('2024');
+  expect(nodes[0].name).not.toBe(nodes[1].name);
+  expect(nodes.map(node => node.records[0])).toEqual([
+    Date.UTC(2023, month, 1),
+    Date.UTC(2024, month, 1),
+  ]);
+});
+
+test.each([
+  [TimeGranularity.WEEK, Date.UTC(2024, 9, 7), 'Mon 07'],
+  [TimeGranularity.DAY, Date.UTC(2024, 9, 7), 'Mon 07'],
+  [TimeGranularity.HOUR, Date.UTC(2024, 9, 7, 6), '06 AM'],
+])('keeps compact smart_date labels for %s', (grain, raw, label) => {
+  const props = new ChartProps<EchartsSunburstFormData>({
+    formData: {
+      ...formData,
+      viz_type: 'sunburst_v2',
+      columns: ['order_date'],
+      dateFormat: 'smart_date',
+      timeGrainSqla: grain,
+      granularitySqla: 'order_date',
+    },
+    width: 800,
+    height: 600,
+    theme: supersetTheme,
+    queriesData: [
+      {
+        colnames: ['order_date', 'sum__value'],
+        coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
+        data: [{ order_date: raw, sum__value: 10 }],
+      },
+    ],
+  });
+  const result = transformProps(props as EchartsSunburstChartProps);
+  expect(
+    (result.echartOptions as { series: { data: { name: string }[] }[] })
+      .series[0].data[0].name,
+  ).toBe(label);
+});
+
+test('a year label is not highlighted by digits inside a selected epoch key', 
() => {
+  const y2000 = Date.UTC(2000, 0, 1);
+  const y2004 = Date.UTC(2004, 0, 1);
+  // The selected key holds 1072915200000, which contains the digits "2000".
+  expect(String(y2004)).toContain('2000');
+  const props = new ChartProps<EchartsSunburstFormData>({
+    formData: {
+      ...formData,
+      viz_type: 'sunburst_v2',
+      columns: ['order_date', 'category'],
+      dateFormat: 'smart_date',
+      timeGrainSqla: TimeGranularity.YEAR,
+      granularitySqla: 'order_date',
+    },
+    width: 800,
+    height: 600,
+    theme: supersetTheme,
+    filterState: { selectedValues: [`${y2004},A`] },
+    queriesData: [
+      {
+        colnames: ['order_date', 'category', 'sum__value'],
+        coltypes: [
+          GenericDataType.Temporal,
+          GenericDataType.String,
+          GenericDataType.Numeric,
+        ],
+        data: [
+          { order_date: y2000, category: 'A', sum__value: 10 },
+          { order_date: y2004, category: 'A', sum__value: 20 },
+        ],
+      },
+    ],
+  });
+  const nodes = (
+    transformProps(props as EchartsSunburstChartProps).echartOptions as {
+      series: {
+        data: {
+          name: string;
+          itemStyle?: { opacity?: number };
+          children?: { itemStyle?: { opacity?: number } }[];
+        }[];
+      }[];
+    }
+  ).series[0].data;
+  expect(nodes.map(node => node.name)).toEqual(['2000', '2004']);
+  expect(nodes[0].itemStyle?.opacity).toBe(0.3);
+  expect(nodes[0].children?.[0].itemStyle?.opacity).toBe(0.3);
+  expect(nodes[1].itemStyle?.opacity).toBeUndefined();
+  expect(nodes[1].children?.[0].itemStyle?.opacity).toBeUndefined();
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/utils.test.ts 
b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/utils.test.ts
new file mode 100644
index 00000000000..f15df8e9a13
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/test/Sunburst/utils.test.ts
@@ -0,0 +1,52 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { TimeGranularity } from '@superset-ui/core';
+import { getSunburstTimeGrain } from '../../src/Sunburst/utils';
+import { EchartsSunburstFormData } from '../../src/Sunburst/types';
+
+const formData: EchartsSunburstFormData = {
+  datasource: '1__table',
+  viz_type: 'sunburst_v2',
+  groupby: [],
+  metric: 'count',
+  columns: ['d1', 'd2'],
+  temporalColumnsLookup: { d1: true, d2: true },
+  timeGrainSqla: TimeGranularity.MONTH,
+};
+
+test('does not grain ambiguous semantic hierarchies with two temporal levels', 
() => {
+  expect(getSunburstTimeGrain(formData, 'd1')).toBeUndefined();
+  expect(getSunburstTimeGrain(formData, 'd2')).toBeUndefined();
+});
+
+test('dashboard time column takes precedence in a two-temporal-level 
hierarchy', () => {
+  const overridden = {
+    ...formData,
+    granularitySqla: 'd1',
+    extraFormData: { granularity_sqla: 'd2' },
+  };
+  expect(getSunburstTimeGrain(overridden, 'd1')).toBeUndefined();
+  expect(getSunburstTimeGrain(overridden, 'd2')).toBe(TimeGranularity.MONTH);
+});
+
+test('ignores unselected temporal columns when identifying a semantic time 
axis', () => {
+  expect(getSunburstTimeGrain({ ...formData, columns: ['d1'] }, 'd1')).toBe(
+    TimeGranularity.MONTH,
+  );
+});
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 b841868cc28..f5b09e3b3fa 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
@@ -23,6 +23,7 @@ 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 { DEFAULT_FORM_DATA } from '../../src/Timeseries/constants';
 import {
   OrientationType,
   TimeseriesChartTransformedProps,
@@ -897,3 +898,42 @@ test('context menu cross-filter is available for a 
temporal bar point', async ()
     },
   ]);
 });
+
+test.each([undefined, 'PT6H' as TimeGranularity])(
+  'temporal drill by compares a naive ISO datetime for grain %s',
+  async grain => {
+    const onContextMenu = jest.fn();
+    renderTimeseries({
+      onContextMenu,
+      groupby: [],
+      formData: {
+        ...DEFAULT_FORM_DATA,
+        datasource: '1__table',
+        viz_type: 'echarts_timeseries_line',
+        metric: 'count',
+        groupby: [],
+        granularitySqla: 'ds',
+        timeGrainSqla: grain,
+      },
+      xAxis: { label: DTTM_ALIAS, type: AxisType.Time },
+    });
+    const raw = Date.UTC(2024, 9, 7, 6);
+    await getLatestEchartProps().eventHandlers?.contextmenu?.({
+      componentType: 'series',
+      seriesName: 'Sales',
+      data: [raw, 100],
+      name: '2024-10-07',
+      event: { stop: jest.fn(), event: { clientX: 10, clientY: 20 } },
+    });
+    // Drill by writes adhoc x-axis comparators straight into SQL, where an
+    // epoch integer would not compare with a timestamp.
+    expect(onContextMenu.mock.lastCall[2].drillBy.xAxisFilters).toEqual([
+      {
+        col: 'ds',
+        op: '==',
+        val: '2024-10-07T06:00:00',
+        formattedVal: expect.any(String),
+      },
+    ]);
+  },
+);
diff --git 
a/superset-frontend/src/components/Chart/DrillBy/DrillByTemporalFilters.test.tsx
 
b/superset-frontend/src/components/Chart/DrillBy/DrillByTemporalFilters.test.tsx
new file mode 100644
index 00000000000..a632514992b
--- /dev/null
+++ 
b/superset-frontend/src/components/Chart/DrillBy/DrillByTemporalFilters.test.tsx
@@ -0,0 +1,256 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { render as renderChart } from '@testing-library/react';
+import fetchMock from 'fetch-mock';
+import {
+  BinaryQueryObjectFilterClause,
+  ChartProps,
+  QueryFormColumn,
+  QueryFormData,
+  TimeGranularity,
+} from '@superset-ui/core';
+import { GenericDataType } from '@apache-superset/core/common';
+import { supersetTheme } from '@apache-superset/core/theme';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import mockState from 'spec/fixtures/mockState';
+import { setupAGGridModules } from 
'@superset-ui/core/components/ThemedAgGridReact';
+import EchartsSunburst from 
'../../../../plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst';
+import transformProps from 
'../../../../plugins/plugin-chart-echarts/src/Sunburst/transformProps';
+import {
+  EchartsSunburstChartProps,
+  EchartsSunburstFormData,
+} from '../../../../plugins/plugin-chart-echarts/src/Sunburst/types';
+import { getTemporalXAxisDrillByFilter } from 
'../../../../plugins/plugin-chart-echarts/src/utils/xAxisDrillByFilter';
+import type { EchartsProps } from 
'../../../../plugins/plugin-chart-echarts/src/types';
+import DrillByModal from './DrillByModal';
+
+setupAGGridModules();
+
+const mockEchart = jest.fn();
+jest.mock(
+  '../../../../plugins/plugin-chart-echarts/src/components/Echart',
+  () => (props: EchartsProps) => {
+    mockEchart(props);
+    return null;
+  },
+);
+
+const CHART_DATA_ENDPOINT = 'glob:*/api/v1/chart/data*';
+
+const ADHOC_ORDER_DATE: QueryFormColumn = {
+  expressionType: 'SQL',
+  sqlExpression: "DATE_TRUNC('month', order_date)",
+  label: 'order_date',
+};
+
+const sunburstFormData = (
+  temporalColumn: QueryFormColumn,
+): EchartsSunburstFormData => ({
+  datasource: '7__table',
+  viz_type: 'sunburst_v2',
+  slice_id: 101,
+  groupby: [],
+  columns: ['category', temporalColumn],
+  metric: 'count',
+  adhoc_filters: [],
+  dateFormat: 'smart_date',
+  timeGrainSqla: TimeGranularity.MONTH,
+  granularitySqla: 'order_date',
+});
+
+/** Right-click the Oct 2024 segment of a rendered Sunburst chart. */
+const sunburstDrillByFilters = (temporalColumn: QueryFormColumn) => {
+  const onContextMenu = jest.fn();
+  const props = transformProps(
+    new ChartProps<EchartsSunburstFormData>({
+      formData: sunburstFormData(temporalColumn),
+      width: 800,
+      height: 600,
+      theme: supersetTheme,
+      queriesData: [
+        {
+          colnames: ['category', 'order_date', 'count'],
+          coltypes: [
+            GenericDataType.String,
+            GenericDataType.Temporal,
+            GenericDataType.Numeric,
+          ],
+          data: [{ category: 'A', order_date: Date.UTC(2024, 9, 1), count: 3 
}],
+        },
+      ],
+      hooks: { onContextMenu },
+    }) as EchartsSunburstChartProps,
+  );
+  renderChart(<EchartsSunburst {...props} />);
+  const { eventHandlers } = mockEchart.mock.lastCall[0] as EchartsProps;
+  const parent = (
+    props.echartOptions as {
+      series: { data: { name: string; children: { name: string }[] }[] }[];
+    }
+  ).series[0].data[0];
+  const segment = parent.children[0];
+  eventHandlers!.contextmenu({
+    data: segment,
+    treePathInfo: [
+      { name: '', dataIndex: 0, value: 3 },
+      { name: parent.name, dataIndex: 1, value: 3 },
+      { name: segment.name, dataIndex: 2, value: 3 },
+    ],
+    event: { stop: jest.fn(), event: { clientX: 0, clientY: 0 } },
+  });
+  return onContextMenu.mock.lastCall[2].drillBy
+    .filters as BinaryQueryObjectFilterClause[];
+};
+
+/** Open Drill by with the filters and return the chart-data request body. */
+const drillByRequest = async (
+  formData: QueryFormData,
+  filters: BinaryQueryObjectFilterClause[],
+  groupbyFieldName: string,
+) => {
+  render(
+    <DrillByModal
+      formData={formData}
+      onHideModal={jest.fn()}
+      dataset={{
+        changed_on_humanized: '01-01-2001',
+        created_on_humanized: '01-01-2001',
+        description: 'desc',
+        table_name: 'my_dataset',
+        editors: [],
+        columns: [
+          { column_name: 'category', verbose_name: null },
+          { column_name: 'region', verbose_name: null },
+        ],
+        verbose_map: {},
+      }}
+      column={{ column_name: 'region', verbose_name: null }}
+      drillByConfig={{ groupbyFieldName, filters }}
+      canDownload={false}
+    />,
+    { useDnd: true, useRedux: true, useRouter: true, initialState: mockState },
+  );
+  await screen.findByRole('dialog');
+  await waitFor(() =>
+    expect(fetchMock.callHistory.called(CHART_DATA_ENDPOINT)).toBe(true),
+  );
+  return JSON.parse(
+    fetchMock.callHistory.lastCall(CHART_DATA_ENDPOINT)?.options
+      ?.body as string,
+  );
+};
+
+beforeEach(() => {
+  fetchMock.post(
+    CHART_DATA_ENDPOINT,
+    { body: {} },
+    { name: CHART_DATA_ENDPOINT },
+  );
+});
+
+afterEach(() => {
+  fetchMock.removeRoutes().clearHistory();
+  mockEchart.mockClear();
+});
+
+test('Sunburst Drill by on a grained segment queries the whole bucket', async 
() => {
+  const filters = sunburstDrillByFilters('order_date');
+  const body = await drillByRequest(
+    sunburstFormData('order_date'),
+    filters,
+    'columns',
+  );
+  // A grain on an equality filter would not survive simpleFilterToAdhoc and
+  // convertFilter, leaving `order_date = <month start>`.
+  expect(body.queries[0].filters).toContainEqual({
+    col: 'order_date',
+    op: 'TEMPORAL_RANGE',
+    val: '2024-10-01T00:00:00 : 2024-11-01T00:00:00',
+  });
+  expect(body.queries[0].filters).not.toContainEqual(
+    expect.objectContaining({ col: 'order_date', op: '==' }),
+  );
+});
+
+test('Sunburst Drill by on an adhoc temporal segment writes a quoted ISO 
datetime', async () => {
+  const filters = sunburstDrillByFilters(ADHOC_ORDER_DATE);
+  const body = await drillByRequest(
+    sunburstFormData(ADHOC_ORDER_DATE),
+    filters,
+    'columns',
+  );
+  expect(body.form_data.adhoc_filters).toContainEqual(
+    expect.objectContaining({
+      expressionType: 'SQL',
+      sqlExpression:
+        "(DATE_TRUNC('month', order_date)) = '2024-10-01T00:00:00'",
+    }),
+  );
+});
+
+test('Timeseries Drill by on an adhoc x-axis label sends an ISO datetime, not 
an epoch', async () => {
+  const xAxis: QueryFormColumn = {
+    expressionType: 'SQL',
+    sqlExpression: "DATE_TRUNC('day', order_date)",
+    label: 'order_day',
+  };
+  // Both Timeseries Drill by callers use this helper with the axis label.
+  const filters = [
+    getTemporalXAxisDrillByFilter('order_day', Date.UTC(2024, 9, 7)),
+    getTemporalXAxisDrillByFilter(
+      'order_day',
+      Date.UTC(2024, 9, 7, 6),
+      'PT6H' as TimeGranularity,
+    ),
+  ] as BinaryQueryObjectFilterClause[];
+  const body = await drillByRequest(
+    {
+      datasource: '7__table',
+      viz_type: 'echarts_timeseries_line',
+      slice_id: 102,
+      x_axis: xAxis,
+      groupby: [],
+      metrics: ['count'],
+      adhoc_filters: [],
+    },
+    filters,
+    'groupby',
+  );
+  // The backend writes an adhoc label comparator without coercion, so an
+  // epoch integer would reach SQL as `<timestamp expr> = 1728259200000`.
+  expect(body.queries[0].filters).toEqual(
+    expect.arrayContaining([
+      { col: 'order_day', op: '==', val: '2024-10-07T00:00:00' },
+      { col: 'order_day', op: '==', val: '2024-10-07T06:00:00' },
+    ]),
+  );
+});
+
+test('Drill by drops the grain of an equality filter', async () => {
+  const body = await drillByRequest(
+    sunburstFormData('order_date'),
+    [{ col: 'order_date', op: '==', val: 1727740800000, grain: 'P1M' }],
+    'columns',
+  );
+  expect(body.queries[0].filters).toContainEqual({
+    col: 'order_date',
+    op: '==',
+    val: 1727740800000,
+  });
+});
diff --git a/superset/models/helpers.py b/superset/models/helpers.py
index 12180f82775..483b6b4ad30 100644
--- a/superset/models/helpers.py
+++ b/superset/models/helpers.py
@@ -2044,6 +2044,19 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
         dataset_timezone = extra.get("timezone")
         return dataset_timezone if isinstance(dataset_timezone, str) else None
 
+    def _has_valid_dataset_timezone(self) -> bool:
+        """
+        Whether the dataset timezone resolves. ``normalize_dttm_col`` applies 
the
+        Hours offset instead when it does not, so filters must invert it too.
+        """
+        if not (dataset_timezone := self.get_dataset_timezone()):
+            return False
+        try:
+            ZoneInfo(dataset_timezone)
+        except (ZoneInfoNotFoundError, ValueError):
+            return False
+        return True
+
     def get_fetch_values_predicate(
         self,
         template_processor: Optional[  # pylint: disable=unused-argument
@@ -4187,6 +4200,7 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
             builtins.type["BaseEngineSpec"]
         ] = None,  # fix(hughhh): Optional[Type[BaseEngineSpec]]
         db_extra: Optional[dict[str, Any]] = None,
+        temporal_offset_hours: int = 0,
     ) -> Optional[FilterValues]:
         if values is None:
             return None
@@ -4232,9 +4246,31 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
             )
             return literal_column(temporal_sql) if temporal_sql is not None 
else value
 
+        def shift_temporal_value(value: Optional[FilterValue]) -> 
Optional[FilterValue]:
+            """Undo the display-only offset before comparing an unshifted 
bucket."""
+            if (
+                not temporal_offset_hours
+                or target_generic_type != GenericDataType.TEMPORAL
+            ):
+                return value
+            if isinstance(value, bool) or value is None:
+                return value
+            if isinstance(value, (int, float)):
+                return value - temporal_offset_hours * 60 * 60 * 1000
+            delta = timedelta(hours=temporal_offset_hours)
+            if isinstance(value, datetime):
+                return value - delta
+            if re.fullmatch(r"[+-]?\d+", value):
+                return str(int(value) - temporal_offset_hours * 60 * 60 * 1000)
+            try:
+                return (datetime.fromisoformat(value) - delta).isoformat(sep=" 
")
+            except ValueError:
+                return value
+
         def handle_single_value(value: Optional[FilterValue]) -> 
Optional[FilterValue]:
             if operator == utils.FilterOperator.TEMPORAL_RANGE:
                 return value
+            value = shift_temporal_value(value)
             if isinstance(value, str):
                 value = value.strip("\t\n")
 
@@ -5394,6 +5430,8 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
             flt_col = flt["col"]
             val = flt.get("val")
             flt_grain = flt.get("grain")
+            filter_grain = flt_grain
+            adhoc_filter_column: AdhocColumn | None = None
             op = utils.FilterOperator(flt["op"].upper())
             col_obj: Optional["TableColumn"] = None
             sqla_col: Optional[Column] = None
@@ -5408,11 +5446,15 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
             if flt_col == utils.DTTM_ALIAS and is_timeseries and dttm_col:
                 col_obj = dttm_col
             elif is_adhoc_column(flt_col):
+                adhoc_filter_column = cast(AdhocColumn, flt_col)
+                if flt_col.get("columnType") == "BASE_AXIS":
+                    filter_grain = flt_col.get("timeGrain")
                 try:
                     sqla_col, adhoc_generic_type = self.adhoc_column_to_sqla(
                         cast("AdhocColumn", flt_col),
                         force_type_check=True,
                         template_processor=template_processor,
+                        apply_dataset_offset=op != 
utils.FilterOperator.TEMPORAL_RANGE,
                     )
                     applied_adhoc_filters_columns.append(flt_col)
                     is_adhoc_sqla_col = True
@@ -5447,16 +5489,18 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
                     and isinstance(flt_col, str)
                     and flt_col in adhoc_columns_by_label
                 ):
+                    adhoc_filter_column = adhoc_columns_by_label[flt_col]
+                    if adhoc_filter_column.get("columnType") == "BASE_AXIS":
+                        filter_grain = adhoc_filter_column.get("timeGrain")
                     sqla_col, _unused = self.adhoc_column_to_sqla(
-                        col=adhoc_columns_by_label[flt_col],
+                        col=adhoc_filter_column,
                         template_processor=template_processor,
+                        apply_dataset_offset=op != 
utils.FilterOperator.TEMPORAL_RANGE,
                     )
                     if isinstance(sqla_col, ColumnElement):
                         applied_adhoc_filters_columns.append(flt_col)
                         is_adhoc_sqla_col = True
 
-            filter_grain = flt.get("grain")
-
             # Check if this filter should be skipped because it was handled in
             # template. Special handling for __timestamp alias: check both the
             # alias and the actual column name
@@ -5482,7 +5526,11 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
                     pass
                 elif col_obj and filter_grain:
                     sqla_col = col_obj.get_timestamp_expression(
-                        time_grain=filter_grain, 
template_processor=template_processor
+                        time_grain=filter_grain,
+                        template_processor=template_processor,
+                        # Bucket comparisons must use the grouping expression.
+                        # Range bounds are shifted separately by 
get_time_filter.
+                        apply_dataset_offset=op != 
utils.FilterOperator.TEMPORAL_RANGE,
                     )
                 elif col_obj:
                     sqla_col = self.convert_tbl_column_to_sqla_col(
@@ -5535,6 +5583,13 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
                     target_generic_type = adhoc_generic_type
                 else:
                     target_generic_type = GenericDataType.STRING
+                # Adhoc aliases absent from dataset metadata are not normalized
+                # by _collect_dttm_labels, so their buckets have no pandas 
offset.
+                offset_column = (
+                    
columns_by_name.get(utils.get_column_name(adhoc_filter_column))
+                    if adhoc_filter_column
+                    else col_obj
+                )
                 eq = self.filter_values_handler(
                     values=val,
                     operator=op,
@@ -5543,6 +5598,34 @@ class ExploreMixin:  # pylint: 
disable=too-many-public-methods
                     is_list_target=is_list_target,
                     db_engine_spec=db_engine_spec,
                     db_extra=self.db_extra,
+                    # Engines without SQL shifts group raw timestamps, then add
+                    # Hours offset in pandas. Invert that display-only shift on
+                    # each selected bucket, not on the underlying column.
+                    temporal_offset_hours=(
+                        (self.offset or 0)
+                        if filter_grain
+                        and offset_column is not None
+                        and offset_column.is_dttm
+                        and op
+                        in {
+                            utils.FilterOperator.EQUALS,
+                            utils.FilterOperator.NOT_EQUALS,
+                            utils.FilterOperator.IN,
+                            utils.FilterOperator.NOT_IN,
+                            utils.FilterOperator.GREATER_THAN,
+                            utils.FilterOperator.LESS_THAN,
+                            utils.FilterOperator.GREATER_THAN_OR_EQUALS,
+                            utils.FilterOperator.LESS_THAN_OR_EQUALS,
+                        }
+                        # A SQL-shifted grouping already matches the label.
+                        and not (
+                            db_engine_spec.supports_temporal_column_shift
+                            and not self.get_dataset_timezone()
+                        )
+                        # pandas skips the offset only for a valid timezone.
+                        and not self._has_valid_dataset_timezone()
+                        else 0
+                    ),
                 )
                 if op in {
                     utils.FilterOperator.IN,
diff --git a/tests/unit_tests/models/test_hours_offset_bound_truncation.py 
b/tests/unit_tests/models/test_hours_offset_bound_truncation.py
index 1f0dcbb8414..3c086fbe8ed 100644
--- a/tests/unit_tests/models/test_hours_offset_bound_truncation.py
+++ b/tests/unit_tests/models/test_hours_offset_bound_truncation.py
@@ -47,7 +47,9 @@ from superset.db_engine_specs.base import BaseEngineSpec
 from superset.db_engine_specs.postgres import PostgresEngineSpec
 from superset.db_engine_specs.sqlite import SqliteEngineSpec
 from superset.models.core import Database
-from superset.superset_typing import AdhocColumn, QueryObjectDict
+from superset.models.helpers import ExploreMixin
+from superset.superset_typing import AdhocColumn, FilterValues, QueryObjectDict
+from superset.utils.core import GenericDataType
 
 # ---------------------------------------------------------------------------
 # Defect 1 -- DATE-column filter bound literal truncated to day precision
@@ -542,10 +544,10 @@ def test_grained_physical_filter_sql_is_unchanged(
     assert query.sql_shifted_temporal_labels == set()
 
 
-def test_grained_adhoc_filter_sql_is_unchanged(
+def test_grained_adhoc_equality_filter_applies_dataset_offset(
     mocker: MockerFixture, app: Flask
 ) -> None:
-    """A BASE_AXIS-shaped adhoc filter does not opt into the axis-only 
shift."""
+    """A BASE_AXIS equality filter shifts before truncation, like its 
grouping."""
     table, _engine = _sqlite_dataset(
         mocker,
         offset=1,
@@ -578,7 +580,8 @@ def test_grained_adhoc_filter_sql_is_unchanged(
     assert query.sql == (
         "SELECT val AS val \n"
         "FROM events \n"
-        "WHERE (DATETIME(ts, 'start of day')) = '2026-08-02 00:00:00' GROUP BY 
val"
+        "WHERE (DATETIME(DATETIME(ts, '+1 hours'), 'start of day')) "
+        "= '2026-08-02 00:00:00' GROUP BY val"
     )
     assert query.sql_shifted_temporal_labels == set()
 
@@ -700,3 +703,472 @@ def 
test_hours_offset_is_applied_before_time_grain_truncation(
     assert bucket_days == {date(2026, 8, 2)}, (
         f"All rows should bucket to 2026-08-02; got {sorted(bucket_days)}"
     )
+
+
[email protected]("adhoc", [False, True])
[email protected]("offset", [1, -1])
+def test_grained_drill_equality_returns_the_segment_rows_with_hours_offset(
+    mocker: MockerFixture,
+    offset: int,
+    adhoc: bool,
+) -> None:
+    """Drill equality uses the same shifted bucket as the segment grouping."""
+    table, _engine = _sqlite_dataset(
+        mocker,
+        offset=offset,
+        column_type="TIMESTAMP",
+        rows=[
+            "2026-08-01 23:30:00",
+            "2026-08-02 00:30:00",
+            "2026-08-02 01:30:00",
+            "2026-08-02 23:30:00",
+        ],
+    )
+    grouped = table.get_query_result(_physical_axis_query(table, 
time_grain="P1D"))
+    segment = grouped.df.loc[grouped.df["__timestamp"] == 
pd.Timestamp("2026-08-02")]
+    expected_count = 3 if offset == 1 else 2
+    assert segment["ct"].tolist() == [expected_count]
+    col: str | AdhocColumn = "ts"
+    if adhoc:
+        col = {
+            "sqlExpression": "ts",
+            "label": "ts",
+            "isColumnReference": True,
+            "columnType": "BASE_AXIS",
+            "timeGrain": "P1D",
+        }
+    query = QueryObject(
+        datasource=table,
+        columns=["ts", "val"],
+        metrics=[],
+        is_timeseries=False,
+        filters=[
+            {
+                "col": col,
+                "op": "==",
+                "val": "2026-08-02 00:00:00" if adhoc else 1785628800000,
+                "grain": "P1D",
+            }
+        ],
+    )
+    detail = table.get_query_result(query)
+    assert len(detail.df) == expected_count
+    assert detail.df["val"].tolist() == [1] * expected_count
+    expected_rows = (
+        ["2026-08-01 23:30:00", "2026-08-02 00:30:00", "2026-08-02 01:30:00"]
+        if offset == 1
+        else ["2026-08-02 01:30:00", "2026-08-02 23:30:00"]
+    )
+    assert sorted(pd.to_datetime(detail.df["ts"]).tolist()) == [
+        pd.Timestamp(value) for value in expected_rows
+    ]
+
+
[email protected]("offset,expected_count", [(1, 3), (-1, 2)])
+def test_grained_drill_filter_by_adhoc_label_applies_hours_offset(
+    mocker: MockerFixture,
+    offset: int,
+    expected_count: int,
+) -> None:
+    """A filter referring to an adhoc axis by label uses its shifted 
expression."""
+    table, _engine = _sqlite_dataset(
+        mocker,
+        offset=offset,
+        column_type="TIMESTAMP",
+        rows=[
+            "2026-08-01 23:30:00",
+            "2026-08-02 00:30:00",
+            "2026-08-02 01:30:00",
+            "2026-08-02 23:30:00",
+        ],
+    )
+    axis: AdhocColumn = {
+        "sqlExpression": "ts",
+        "label": "bucket",
+        "isColumnReference": True,
+        "columnType": "BASE_AXIS",
+        "timeGrain": "P1D",
+    }
+    result = table.get_query_result(
+        QueryObject(
+            datasource=table,
+            columns=[axis],
+            metrics=[
+                {"expressionType": "SQL", "sqlExpression": "COUNT(*)", 
"label": "ct"}
+            ],
+            is_timeseries=False,
+            filters=[{"col": "bucket", "op": "==", "val": "2026-08-02 
00:00:00"}],
+        )
+    )
+    assert result.df["ct"].tolist() == [expected_count]
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected](
+    "filter_path", ["physical", "verbose", "timestamp", "adhoc", "label"]
+)
[email protected]("offset", [0, 1, -1, 25, -25])
[email protected]("operator", ["==", "!=", "IN", "NOT IN", ">", "<", 
">=", "<="])
+def test_grained_drill_matches_every_bucket_with_hours_offset(
+    mocker: MockerFixture,
+    supports_sql_shift: bool,
+    filter_path: str,
+    offset: int,
+    operator: str,
+) -> None:
+    """Bucket comparisons invert display-only offsets for every operand."""
+    mocker.patch.object(
+        SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+    )
+    raw_rows = [
+        "2026-08-01 23:30:00",
+        "2026-08-02 00:30:00",
+        "2026-08-02 01:30:00",
+        "2026-08-02 23:30:00",
+    ]
+    table, _engine = _sqlite_dataset(mocker, offset, "TIMESTAMP", raw_rows)
+    table.columns[0].verbose_name = "Event time"
+    axis: AdhocColumn = {
+        "sqlExpression": "ts",
+        "label": "bucket" if filter_path == "label" else "ts",
+        "isColumnReference": True,
+        "columnType": "BASE_AXIS",
+        "timeGrain": "P1D",
+    }
+    grouped_query = _physical_axis_query(table, time_grain="P1D")
+    if filter_path in {"adhoc", "label"}:
+        grouped_query = QueryObject(
+            datasource=table,
+            columns=[axis],
+            metrics=[
+                {"expressionType": "SQL", "sqlExpression": "COUNT(*)", 
"label": "ct"}
+            ],
+            is_timeseries=False,
+        )
+    grouped = table.get_query_result(grouped_query)
+    bucket_label = (
+        str(axis["label"]) if filter_path in {"adhoc", "label"} else 
"__timestamp"
+    )
+    assert grouped.df["ct"].sum() == len(raw_rows)
+    expected_buckets = [
+        (pd.Timestamp(value) + pd.Timedelta(hours=offset)).floor("D")
+        if supports_sql_shift
+        else pd.Timestamp(value).floor("D")
+        + pd.Timedelta(hours=0 if filter_path == "label" else offset)
+        for value in raw_rows
+    ]
+    for _, segment in grouped.df.iterrows():
+        bucket = pd.Timestamp(segment[bucket_label])
+        matching_buckets = [
+            expected_bucket
+            for expected_bucket in expected_buckets
+            if {
+                "==": expected_bucket == bucket,
+                "!=": expected_bucket != bucket,
+                "IN": expected_bucket == bucket,
+                "NOT IN": expected_bucket != bucket,
+                ">": expected_bucket > bucket,
+                "<": expected_bucket < bucket,
+                ">=": expected_bucket >= bucket,
+                "<=": expected_bucket <= bucket,
+            }[operator]
+        ]
+        assert segment["ct"] == expected_buckets.count(bucket)
+        expected_rows = sorted(
+            pd.Timestamp(value)
+            for value, expected_bucket in zip(raw_rows, expected_buckets, 
strict=True)
+            if expected_bucket in matching_buckets
+        )
+        filter_columns: dict[str, str | AdhocColumn] = {
+            "physical": "ts",
+            "verbose": "Event time",
+            "timestamp": "__timestamp",
+            "adhoc": axis,
+            "label": "bucket",
+        }
+        filter_column = filter_columns[filter_path]
+        # Adhoc filter values use SQL-formatted dates; physical columns 
exercise
+        # epoch coercion into an engine-native timestamp literal.
+        value = (
+            str(bucket)
+            if filter_path in {"adhoc", "label"}
+            else int(bucket.value // 10**6)
+        )
+        query = QueryObject(
+            datasource=table,
+            columns=[axis] if filter_path == "label" else ["ts", "val"],
+            metrics=[
+                {"expressionType": "SQL", "sqlExpression": "COUNT(*)", 
"label": "ct"}
+            ]
+            if filter_path == "label"
+            else [],
+            is_timeseries=filter_path == "timestamp",
+            granularity="ts" if filter_path == "timestamp" else None,
+            filters=[
+                {
+                    "col": filter_column,
+                    "op": operator,
+                    "val": [value] if operator in {"IN", "NOT IN"} else value,
+                    **({} if filter_path in {"adhoc", "label"} else {"grain": 
"P1D"}),
+                }
+            ],
+        )
+        detail = table.get_query_result(query)
+        if filter_path == "label":
+            actual = sorted(
+                zip(
+                    pd.to_datetime(detail.df["bucket"]),
+                    detail.df["ct"],
+                    strict=True,
+                )
+            )
+            expected = sorted(
+                (value, matching_buckets.count(value))
+                for value in dict.fromkeys(matching_buckets)
+            )
+            assert actual == expected
+        else:
+            assert len(detail.df) == len(expected_rows)
+            # A timeseries detail query also normalizes its returned time axis.
+            if filter_path == "timestamp":
+                expected_rows = [
+                    value + pd.Timedelta(hours=offset) for value in 
expected_rows
+                ]
+            assert sorted(pd.to_datetime(detail.df["ts"]).tolist()) == 
expected_rows
+
+
[email protected](
+    "value,expected",
+    [
+        (1785632400000, 1785628800000),
+        (1785632400000.0, 1785628800000.0),
+        ("1785632400000", "1785628800000"),
+        ("2026-08-02T01:00:00", "2026-08-02 00:00:00"),
+        (datetime(2026, 8, 2, 1), datetime(2026, 8, 2)),
+        ([1785632400000, None], [1785628800000, None]),
+        (None, None),
+        (True, True),
+        ("not a date", "not a date"),
+    ],
+)
+def test_display_only_bucket_offset_value_formats(
+    value: FilterValues | None, expected: FilterValues | None
+) -> None:
+    """Fallback shifts retain value types and preserve invalid or null 
values."""
+    assert (
+        ExploreMixin.filter_values_handler(
+            values=value,
+            operator="IN" if isinstance(value, list) else "==",
+            target_generic_type=GenericDataType.TEMPORAL,
+            is_list_target=isinstance(value, list),
+            temporal_offset_hours=1,
+        )
+        == expected
+    )
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected]("offset", [1, -1])
[email protected]("operator", ["==", "IN", "NOT IN"])
+def test_grained_drill_with_dataset_timezone_ignores_hours_offset(
+    mocker: MockerFixture, supports_sql_shift: bool, offset: int, operator: str
+) -> None:
+    """Dataset timezone takes precedence over Hours offset on both engine 
paths."""
+    mocker.patch.object(
+        SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+    )
+    table, _engine = _sqlite_dataset(
+        mocker, offset, "TIMESTAMP", ["2026-08-01 23:30:00", "2026-08-02 
00:30:00"]
+    )
+    table.extra = '{"timezone": "UTC"}'
+    grouped = table.get_query_result(_physical_axis_query(table, 
time_grain="P1D"))
+    assert grouped.df["__timestamp"].tolist() == [
+        pd.Timestamp("2026-08-01"),
+        pd.Timestamp("2026-08-02"),
+    ]
+    assert grouped.df["ct"].tolist() == [1, 1]
+    for bucket in grouped.df["__timestamp"]:
+        detail = table.get_query_result(
+            QueryObject(
+                datasource=table,
+                columns=["ts", "val"],
+                metrics=[],
+                is_timeseries=False,
+                filters=[
+                    {
+                        "col": "ts",
+                        "op": operator,
+                        "grain": "P1D",
+                        "val": [int(bucket.value // 10**6)]
+                        if operator in {"IN", "NOT IN"}
+                        else int(bucket.value // 10**6),
+                    }
+                ],
+            )
+        )
+        assert len(detail.df) == 1
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected]("offset", [1, -1])
[email protected]("operator", ["IN", "NOT IN"])
+def test_grained_membership_filter_shifts_each_bucket_value(
+    mocker: MockerFixture, supports_sql_shift: bool, offset: int, operator: str
+) -> None:
+    """Multi-bucket filters invert every displayed operand without losing 
NULL."""
+    mocker.patch.object(
+        SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+    )
+    raw_rows = [
+        "2026-08-01 12:30:00",
+        "2026-08-02 12:30:00",
+        "2026-08-03 12:30:00",
+    ]
+    table, engine = _sqlite_dataset(mocker, offset, "TIMESTAMP", raw_rows)
+    with engine.begin() as connection:
+        connection.exec_driver_sql("INSERT INTO events VALUES (NULL, 1)")
+    grouped = table.get_query_result(_physical_axis_query(table, 
time_grain="P1D"))
+    buckets = sorted(pd.to_datetime(grouped.df["__timestamp"]).dropna())
+    assert len(buckets) == 3
+    assert grouped.df["ct"].sum() == 3
+    # Select nonadjacent buckets, so NOT IN must retain the middle bucket.
+    values: FilterValues = [
+        int(buckets[0].value // 10**6),
+        int(buckets[2].value // 10**6),
+        None,
+    ]
+    detail = table.get_query_result(
+        QueryObject(
+            datasource=table,
+            columns=["ts", "val"],
+            metrics=[],
+            is_timeseries=False,
+            filters=[{"col": "ts", "op": operator, "val": values, "grain": 
"P1D"}],
+        )
+    )
+    if operator == "IN":
+        assert len(detail.df) == 3
+        assert pd.to_datetime(detail.df["ts"]).isna().sum() == 1
+        expected_rows = [pd.Timestamp(raw_rows[0]), pd.Timestamp(raw_rows[2])]
+    else:
+        assert len(detail.df) == 1
+        assert pd.to_datetime(detail.df["ts"]).isna().sum() == 0
+        expected_rows = [pd.Timestamp(raw_rows[1])]
+    assert sorted(pd.to_datetime(detail.df["ts"]).dropna()) == expected_rows
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected]("offset", [1, -1])
[email protected]("operator", ["==", "IN", "NOT IN"])
+def test_grained_drill_with_invalid_dataset_timezone_uses_hours_offset(
+    mocker: MockerFixture, supports_sql_shift: bool, offset: int, operator: str
+) -> None:
+    """An unresolvable timezone falls back to Hours offset in pandas and 
filters."""
+    mocker.patch.object(
+        SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+    )
+    raw_rows = ["2026-08-01 23:30:00", "2026-08-02 00:30:00"]
+    table, _engine = _sqlite_dataset(mocker, offset, "TIMESTAMP", raw_rows)
+    table.extra = '{"timezone": "Not/A_Zone"}'
+    grouped = table.get_query_result(_physical_axis_query(table, 
time_grain="P1D"))
+    # The SQL shift needs no configured timezone, so grouping stays unshifted
+    # on both engine groups; pandas then adds the Hours offset to each label.
+    assert grouped.df["__timestamp"].tolist() == [
+        pd.Timestamp("2026-08-01") + pd.Timedelta(hours=offset),
+        pd.Timestamp("2026-08-02") + pd.Timedelta(hours=offset),
+    ]
+    assert grouped.df["ct"].tolist() == [1, 1]
+    for bucket in grouped.df["__timestamp"]:
+        value = int(bucket.value // 10**6)
+        detail = table.get_query_result(
+            QueryObject(
+                datasource=table,
+                columns=["ts", "val"],
+                metrics=[],
+                is_timeseries=False,
+                filters=[
+                    {
+                        "col": "ts",
+                        "op": operator,
+                        "grain": "P1D",
+                        "val": [value] if operator in {"IN", "NOT IN"} else 
value,
+                    }
+                ],
+            )
+        )
+        assert len(detail.df) == 1
+
+
[email protected]("supports_sql_shift", [False, True])
[email protected]("offset", [0, 1, -1])
+def test_drill_by_bucket_range_returns_the_segment_rows_with_hours_offset(
+    mocker: MockerFixture, supports_sql_shift: bool, offset: int
+) -> None:
+    """
+    Drill by drops a filter grain, so a grained segment is sent as the
+    TEMPORAL_RANGE of its displayed bucket (the shape the Sunburst and
+    Timeseries Drill by requests carry after convertFilter).
+    """
+    mocker.patch.object(
+        SqliteEngineSpec, "supports_temporal_column_shift", supports_sql_shift
+    )
+    raw_rows = [
+        "2026-08-01 23:30:00",
+        "2026-08-02 00:30:00",
+        "2026-08-02 12:00:00",
+        "2026-08-02 23:30:00",
+    ]
+    table, _engine = _sqlite_dataset(mocker, offset, "TIMESTAMP", raw_rows)
+    grouped = table.get_query_result(_physical_axis_query(table, 
time_grain="P1D"))
+    assert grouped.df["ct"].sum() == len(raw_rows)
+    for _, segment in grouped.df.iterrows():
+        since = pd.Timestamp(segment["__timestamp"])
+        until = since + pd.Timedelta(days=1)
+        detail = table.get_query_result(
+            QueryObject(
+                datasource=table,
+                columns=["ts", "val"],
+                metrics=[],
+                is_timeseries=False,
+                filters=[
+                    {
+                        "col": "ts",
+                        "op": "TEMPORAL_RANGE",
+                        "val": f"{since:%Y-%m-%dT%H:%M:%S} : 
{until:%Y-%m-%dT%H:%M:%S}",
+                    }
+                ],
+            )
+        )
+        assert len(detail.df) == segment["ct"]
+
+
[email protected](
+    "value,expected",
+    [
+        (1785628800000, "= 1785628800000"),
+        ("2026-08-02T00:00:00", "= '2026-08-02T00:00:00'"),
+    ],
+)
+def test_adhoc_label_drill_comparator_is_not_coerced(
+    mocker: MockerFixture, app: Flask, value: FilterValues, expected: str
+) -> None:
+    """
+    A filter on an adhoc x-axis label reaches SQL as sent, so Drill by must 
send
+    a datetime literal rather than an epoch integer.
+    """
+    table, _engine = _sqlite_dataset(mocker, 0, "TIMESTAMP", rows=[])
+    axis: AdhocColumn = {
+        "sqlExpression": "DATETIME(ts, 'start of day')",
+        "label": "order_day",
+        "columnType": "BASE_AXIS",
+    }
+    query_object = QueryObject(
+        datasource=table,
+        columns=[axis],
+        metrics=[{"expressionType": "SQL", "sqlExpression": "COUNT(*)", 
"label": "ct"}],
+        is_timeseries=False,
+        filters=[{"col": "order_day", "op": "==", "val": value}],
+    )
+    with app.test_request_context():
+        sql = table.get_query_str_extended(query_object.to_dict(), 
mutate=False).sql
+    assert f"WHERE (DATETIME(ts, 'start of day')) {expected} " in sql

Reply via email to