diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/dateFilterComparator.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/dateFilterComparator.ts index 46fe46de5e8..9d66f89342b 100644 --- a/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/dateFilterComparator.ts +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/src/utils/dateFilterComparator.ts @@ -17,25 +17,53 @@ * under the License. */ -const dateFilterComparator = (filterDate: Date, cellValue: Date) => { +/** + * Timezone-safe date comparator for AG Grid date filters. + * + * This comparator normalizes both dates to UTC midnight before comparison, + * fixing the off-by-one day bug that occurs when: + * - User's timezone differs from UTC + * - Data is stored in UTC but filtered in local time + * - Midnight boundary crossing due to timezone offset + * + * Bug references: + * - AG Grid Issue #8611: UTC Date Editor Problem + * - AG Grid Issue #3921: DateFilter timezone regression + * + */ +const dateFilterComparator = ( + filterDate: Date, + cellValue: Date | null | undefined, +) => { + if (cellValue == null) { + return -1; + } + const cellDate = new Date(cellValue); - cellDate.setHours(0, 0, 0, 0); - if (Number.isNaN(cellDate?.getTime())) return -1; + if (Number.isNaN(cellDate.getTime())) { + return -1; + } - const cellDay = cellDate.getDate(); - const cellMonth = cellDate.getMonth(); - const cellYear = cellDate.getFullYear(); + // Filter date from AG Grid uses local timezone (what the user selected) + const filterUTC = Date.UTC( + filterDate.getFullYear(), + filterDate.getMonth(), + filterDate.getDate(), + ); - const filterDay = filterDate.getDate(); - const filterMonth = filterDate.getMonth(); - const filterYear = filterDate.getFullYear(); + // Cell data is in UTC - extract UTC components to compare actual dates + const cellUTC = Date.UTC( + cellDate.getUTCFullYear(), + cellDate.getUTCMonth(), + cellDate.getUTCDate(), + ); - if (cellYear < filterYear) return -1; - if (cellYear > filterYear) return 1; - if (cellMonth < filterMonth) return -1; - if (cellMonth > filterMonth) return 1; - if (cellDay < filterDay) return -1; - if (cellDay > filterDay) return 1; + if (cellUTC < filterUTC) { + return -1; + } + if (cellUTC > filterUTC) { + return 1; + } return 0; }; diff --git a/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/dateFilterComparator.test.ts b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/dateFilterComparator.test.ts new file mode 100644 index 00000000000..f4ede9d1ba8 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-ag-grid-table/test/utils/dateFilterComparator.test.ts @@ -0,0 +1,124 @@ +/** + * 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 dateFilterComparator from '../../src/utils/dateFilterComparator'; + +test('returns 0 when filter date equals cell date', () => { + const filterDate = new Date(2003, 9, 8); // Oct 8, 2003 local + const cellDate = new Date('2003-10-08T12:00:00Z'); // Oct 8, 2003 UTC + + expect(dateFilterComparator(filterDate, cellDate)).toBe(0); +}); + +test('returns -1 when cell date is before filter date', () => { + const filterDate = new Date(2003, 9, 10); // Oct 10, 2003 + const cellDate = new Date('2003-10-08T12:00:00Z'); // Oct 8, 2003 + + expect(dateFilterComparator(filterDate, cellDate)).toBe(-1); +}); + +test('returns 1 when cell date is after filter date', () => { + const filterDate = new Date(2003, 9, 8); // Oct 8, 2003 + const cellDate = new Date('2003-10-10T12:00:00Z'); // Oct 10, 2003 + + expect(dateFilterComparator(filterDate, cellDate)).toBe(1); +}); + +test('returns -1 when cell value is null', () => { + const filterDate = new Date(2003, 9, 8); + + expect(dateFilterComparator(filterDate, null)).toBe(-1); +}); + +test('returns -1 when cell value is undefined', () => { + const filterDate = new Date(2003, 9, 8); + + expect(dateFilterComparator(filterDate, undefined)).toBe(-1); +}); + +test('returns -1 when cell value is an invalid date string', () => { + const filterDate = new Date(2003, 9, 8); + const cellDate = new Date('invalid-date'); + + expect(dateFilterComparator(filterDate, cellDate)).toBe(-1); +}); + +test('handles year boundary - cell in previous year', () => { + const filterDate = new Date(2024, 0, 1); // Jan 1, 2024 + const cellDate = new Date('2023-12-31T12:00:00Z'); // Dec 31, 2023 + + expect(dateFilterComparator(filterDate, cellDate)).toBe(-1); +}); + +test('handles year boundary - cell in next year', () => { + const filterDate = new Date(2023, 11, 31); // Dec 31, 2023 + const cellDate = new Date('2024-01-01T12:00:00Z'); // Jan 1, 2024 + + expect(dateFilterComparator(filterDate, cellDate)).toBe(1); +}); + +test('handles month boundary - cell in previous month', () => { + const filterDate = new Date(2003, 9, 1); // Oct 1, 2003 + const cellDate = new Date('2003-09-30T12:00:00Z'); // Sep 30, 2003 + + expect(dateFilterComparator(filterDate, cellDate)).toBe(-1); +}); + +test('handles month boundary - cell in next month', () => { + const filterDate = new Date(2003, 8, 30); // Sep 30, 2003 + const cellDate = new Date('2003-10-01T12:00:00Z'); // Oct 1, 2003 + + expect(dateFilterComparator(filterDate, cellDate)).toBe(1); +}); + +test('matches UTC midnight timestamp', () => { + const filterDate = new Date(2003, 9, 8); // Oct 8, 2003 + const cellDate = new Date('2003-10-08T00:00:00Z'); // Oct 8, 2003 00:00 UTC + + expect(dateFilterComparator(filterDate, cellDate)).toBe(0); +}); + +test('matches UTC end-of-day timestamp', () => { + const filterDate = new Date(2003, 9, 8); // Oct 8, 2003 + const cellDate = new Date('2003-10-08T23:59:59Z'); // Oct 8, 2003 23:59 UTC + + expect(dateFilterComparator(filterDate, cellDate)).toBe(0); +}); + +test('correctly compares dates from ISO string cell values', () => { + const filterDate = new Date(2003, 9, 8); + const cellDate = new Date('2003-10-08T00:00:00Z'); + + expect(dateFilterComparator(filterDate, cellDate)).toBe(0); +}); + +test('handles cell value created from UTC timestamp', () => { + const filterDate = new Date(2003, 9, 8); + // Oct 8, 2003 12:00:00 UTC + const cellDate = new Date(Date.UTC(2003, 9, 8, 12, 0, 0)); + + expect(dateFilterComparator(filterDate, cellDate)).toBe(0); +}); + +test('compares only date components, ignoring time', () => { + const filterDate = new Date(2003, 9, 8, 0, 0, 0); // Oct 8 at midnight local + const cellDate = new Date('2003-10-08T18:30:45Z'); // Oct 8 at 6:30pm UTC + + expect(dateFilterComparator(filterDate, cellDate)).toBe(0); +});