From 43d729f9455a699e0d76f1e89bcadf76ed78f581 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 2 Jul 2026 22:36:07 -0700 Subject: [PATCH] feat(calendar): migrate cal_heatmap chart to v1 chart data API Adds a buildQuery mirroring the legacy CalHeatmapViz.query_obj (a timeseries query whose time grain is forced from the subdomain granularity) and ports the get_data reshape into a transformData helper: per-metric value maps keyed by unix seconds, plus the domain range computed from the query's from_dttm/to_dttm with dateutil.relativedelta-compatible calendar arithmetic. Legacy pre-shaped payloads pass through untouched. No DB migration needed: viz_type is unchanged and legacy granularity_sqla/time_range form data flows through extractExtras. Co-Authored-By: Claude Fable 5 --- .../src/buildQuery.ts | 49 +++++++ .../legacy-plugin-chart-calendar/src/index.ts | 2 +- .../src/transformData.ts | 121 ++++++++++++++++++ .../src/transformProps.ts | 28 +++- .../test/buildQuery.test.ts | 45 +++++++ .../test/transformData.test.ts | 92 +++++++++++++ 6 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 superset-frontend/plugins/legacy-plugin-chart-calendar/src/buildQuery.ts create mode 100644 superset-frontend/plugins/legacy-plugin-chart-calendar/src/transformData.ts create mode 100644 superset-frontend/plugins/legacy-plugin-chart-calendar/test/buildQuery.test.ts create mode 100644 superset-frontend/plugins/legacy-plugin-chart-calendar/test/transformData.test.ts diff --git a/superset-frontend/plugins/legacy-plugin-chart-calendar/src/buildQuery.ts b/superset-frontend/plugins/legacy-plugin-chart-calendar/src/buildQuery.ts new file mode 100644 index 000000000000..29fb5eaf783c --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-calendar/src/buildQuery.ts @@ -0,0 +1,49 @@ +/** + * 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 { buildQueryContext, QueryFormData, TimeGranularity } from '@superset-ui/core'; + +const SUBDOMAIN_TO_TIME_GRAIN: Record = { + min: TimeGranularity.MINUTE, + hour: TimeGranularity.HOUR, + day: TimeGranularity.DAY, + week: TimeGranularity.WEEK, + month: TimeGranularity.MONTH, + year: TimeGranularity.YEAR, +}; + +/** + * Mirrors the legacy CalHeatmapViz.query_obj: a timeseries query whose + * time grain is forced from the subdomain granularity control. + */ +export default function buildQuery(formData: QueryFormData) { + const { subdomain_granularity } = formData; + const timeGrain = + SUBDOMAIN_TO_TIME_GRAIN[(subdomain_granularity as string) ?? 'min'] ?? + TimeGranularity.MINUTE; + return buildQueryContext(formData, baseQueryObject => [ + { + ...baseQueryObject, + is_timeseries: true, + extras: { + ...baseQueryObject.extras, + time_grain_sqla: timeGrain, + }, + }, + ]); +} diff --git a/superset-frontend/plugins/legacy-plugin-chart-calendar/src/index.ts b/superset-frontend/plugins/legacy-plugin-chart-calendar/src/index.ts index ea2ce7e47ec2..fb326c7a5d2d 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-calendar/src/index.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-calendar/src/index.ts @@ -43,13 +43,13 @@ const metadata = new ChartMetadata({ ], thumbnail, thumbnailDark, - useLegacyApi: true, }); export default class CalendarChartPlugin extends ChartPlugin { constructor() { super({ loadChart: () => import('./ReactCalendar'), + loadBuildQuery: () => import('./buildQuery'), metadata, transformProps, controlPanel, diff --git a/superset-frontend/plugins/legacy-plugin-chart-calendar/src/transformData.ts b/superset-frontend/plugins/legacy-plugin-chart-calendar/src/transformData.ts new file mode 100644 index 000000000000..11e8c7c716ed --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-calendar/src/transformData.ts @@ -0,0 +1,121 @@ +/** + * 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 { t } from '@apache-superset/core/translation'; +import { DTTM_ALIAS } from '@superset-ui/core'; + +export interface CalHeatmapPayload { + data: Record>; + start: number; + domain?: string; + subdomain?: string; + range: number; +} + +const daysInMonth = (year: number, month: number) => + new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + +/** Adds calendar months, clamping the day like dateutil.relativedelta. */ +const addMonths = (date: Date, months: number): Date => { + const totalMonths = date.getUTCFullYear() * 12 + date.getUTCMonth() + months; + const year = Math.floor(totalMonths / 12); + const month = totalMonths % 12; + const day = Math.min(date.getUTCDate(), daysInMonth(year, month)); + return new Date( + Date.UTC( + year, + month, + day, + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + date.getUTCMilliseconds(), + ), + ); +}; + +/** + * Calendar delta between two dates with dateutil.relativedelta semantics: + * whole months first, then remaining days (weeks = days // 7). + */ +export const calendarDelta = (start: Date, end: Date) => { + let months = + (end.getUTCFullYear() - start.getUTCFullYear()) * 12 + + (end.getUTCMonth() - start.getUTCMonth()); + if (addMonths(start, months) > end) { + months -= 1; + } + const anchor = addMonths(start, months); + const days = Math.floor( + (end.getTime() - anchor.getTime()) / (24 * 60 * 60 * 1000), + ); + return { + years: Math.trunc(months / 12), + months: months % 12, + weeks: Math.floor(days / 7), + }; +}; + +/** + * Ports the legacy CalHeatmapViz.get_data reshape: per-metric value maps + * keyed by unix seconds, plus the domain range computed from the query's + * time bounds exactly like the backend's relativedelta arithmetic. + */ +export default function transformData( + records: Record[], + metricLabels: string[], + fromDttm: number | null | undefined, + toDttm: number | null | undefined, + domain: string, + subdomain: string, +): CalHeatmapPayload { + if (fromDttm == null || toDttm == null) { + throw new Error(t('Please provide both time bounds (Since and Until)')); + } + const data: Record> = {}; + metricLabels.forEach(metric => { + const values: Record = {}; + records.forEach(record => { + const timestamp = record[DTTM_ALIAS]; + if (timestamp != null) { + values[String((timestamp as number) / 1000)] = record[metric]; + } + }); + data[metric] = values; + }); + + const start = new Date(fromDttm); + const end = new Date(toDttm); + const delta = calendarDelta(start, end); + const diffSecs = (toDttm - fromDttm) / 1000; + + let range: number; + if (domain === 'year') { + range = end.getUTCFullYear() - start.getUTCFullYear() + 1; + } else if (domain === 'month') { + range = delta.years * 12 + delta.months + 1; + } else if (domain === 'week') { + range = delta.years * 53 + delta.weeks + 1; + } else if (domain === 'day') { + range = Math.floor(diffSecs / (24 * 60 * 60)) + 1; + } else { + range = Math.floor(diffSecs / (60 * 60)) + 1; + } + + return { data, start: fromDttm, domain, subdomain, range }; +} diff --git a/superset-frontend/plugins/legacy-plugin-chart-calendar/src/transformProps.ts b/superset-frontend/plugins/legacy-plugin-chart-calendar/src/transformProps.ts index b20773ee70d6..4a3fcafdc72a 100644 --- a/superset-frontend/plugins/legacy-plugin-chart-calendar/src/transformProps.ts +++ b/superset-frontend/plugins/legacy-plugin-chart-calendar/src/transformProps.ts @@ -17,8 +17,15 @@ * under the License. */ -import { ChartProps, getNumberFormatter } from '@superset-ui/core'; +import { + ChartProps, + ensureIsArray, + getMetricLabel, + getNumberFormatter, + QueryFormMetric, +} from '@superset-ui/core'; import { getFormattedUTCTime } from './utils'; +import transformData from './transformData'; export default function transformProps(chartProps: ChartProps) { const { height, formData, queriesData, datasource } = chartProps; @@ -42,9 +49,26 @@ export default function transformProps(chartProps: ChartProps) { getFormattedUTCTime(ts, xAxisTimeFormat); const valueFormatter = getNumberFormatter(yAxisFormat); + // The legacy explore_json endpoint computed the per-metric value maps + // and domain range server-side; v1 responses arrive as flat records. + const { data: rawData, from_dttm: fromDttm, to_dttm: toDttm } = + queriesData[0]; + const data = Array.isArray(rawData) + ? transformData( + rawData, + ensureIsArray(formData.metrics as QueryFormMetric[]).map( + getMetricLabel, + ), + fromDttm, + toDttm, + domainGranularity, + subdomainGranularity, + ) + : rawData; + return { height, - data: queriesData[0].data, + data, cellPadding, cellRadius, cellSize, diff --git a/superset-frontend/plugins/legacy-plugin-chart-calendar/test/buildQuery.test.ts b/superset-frontend/plugins/legacy-plugin-chart-calendar/test/buildQuery.test.ts new file mode 100644 index 000000000000..54a95eb939c1 --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-calendar/test/buildQuery.test.ts @@ -0,0 +1,45 @@ +/** + * 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 { QueryFormData } from '@superset-ui/core'; +import buildQuery from '../src/buildQuery'; + +const formData: QueryFormData = { + datasource: '5__table', + granularity_sqla: 'ds', + time_range: 'Last year', + viz_type: 'cal_heatmap', + metrics: ['sum__num'], + domain_granularity: 'month', + subdomain_granularity: 'day', +}; + +test('forces the time grain from the subdomain granularity', () => { + const [query] = buildQuery(formData).queries; + expect(query.extras?.time_grain_sqla).toEqual('P1D'); + expect(query.is_timeseries).toBe(true); + expect(query.metrics).toEqual(['sum__num']); +}); + +test('defaults the time grain to minutes like the legacy backend', () => { + const [query] = buildQuery({ + ...formData, + subdomain_granularity: undefined, + }).queries; + expect(query.extras?.time_grain_sqla).toEqual('PT1M'); +}); diff --git a/superset-frontend/plugins/legacy-plugin-chart-calendar/test/transformData.test.ts b/superset-frontend/plugins/legacy-plugin-chart-calendar/test/transformData.test.ts new file mode 100644 index 000000000000..507d634caecc --- /dev/null +++ b/superset-frontend/plugins/legacy-plugin-chart-calendar/test/transformData.test.ts @@ -0,0 +1,92 @@ +/** + * 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 transformData, { calendarDelta } from '../src/transformData'; + +const jan1 = Date.UTC(2024, 0, 1); // 1704067200000 +const jan2 = Date.UTC(2024, 0, 2); + +test('keys per-metric values by unix seconds like the legacy backend', () => { + const { data, start } = transformData( + [ + { __timestamp: jan1, sum__num: 10 }, + { __timestamp: jan2, sum__num: 20 }, + ], + ['sum__num'], + jan1, + Date.UTC(2024, 11, 31), + 'month', + 'day', + ); + expect(data.sum__num).toEqual({ + '1704067200': 10, + '1704153600': 20, + }); + expect(start).toEqual(jan1); +}); + +test('throws when time bounds are missing', () => { + expect(() => + transformData([], ['sum__num'], null, jan1, 'month', 'day'), + ).toThrow('time bounds'); +}); + +test.each([ + ['year', Date.UTC(2022, 5, 15), Date.UTC(2024, 1, 1), 3], + // Jan 15 -> Apr 10 is 2 whole months + 26 days => 2 + 1 + ['month', Date.UTC(2024, 0, 15), Date.UTC(2024, 3, 10), 3], + ['day', jan1, Date.UTC(2024, 0, 8, 12), 8], + ['hour', jan1, Date.UTC(2024, 0, 1, 5, 30), 6], +])('computes the %s domain range', (domain, start, end, expected) => { + const { range } = transformData( + [], + ['m'], + start as number, + end as number, + domain as string, + 'day', + ); + expect(range).toEqual(expected); +}); + +test('computes week ranges with relativedelta semantics', () => { + // 2024-01-01 -> 2024-03-20: 2 full months + 19 days => weeks = 2 + const { range } = transformData( + [], + ['m'], + Date.UTC(2024, 0, 1), + Date.UTC(2024, 2, 20), + 'week', + 'day', + ); + expect(range).toEqual(0 * 53 + 2 + 1); +}); + +test('calendarDelta matches dateutil for month-end clamping', () => { + // Jan 31 -> Feb 29 is a full month in dateutil terms (clamped day) + expect( + calendarDelta( + new Date(Date.UTC(2024, 0, 31)), + new Date(Date.UTC(2024, 1, 29)), + ), + ).toEqual({ + years: 0, + months: 1, + weeks: 0, + }); +});