diff --git a/CHANGELOG.md b/CHANGELOG.md index 8422b2aa1..0b12d2369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Not released +- Fix time zone handling in week counts, separate getMonday and getUTCMonday utilities [#879](https://github.com/CartoDB/carto-react/pull/879) + ## 3.0.0 ### 3.0.0-alpha.12 (2024-06-14) diff --git a/packages/react-core/__tests__/utils/dateUtils.test.js b/packages/react-core/__tests__/utils/dateUtils.test.js new file mode 100644 index 000000000..43c59b480 --- /dev/null +++ b/packages/react-core/__tests__/utils/dateUtils.test.js @@ -0,0 +1,66 @@ +import { getMonday, getUTCMonday } from '../../src/utils/dateUtils'; + +const TEST_CASES = [ + { + title: 'monday', + date: [2023, 9, 4], + expected: [2023, 9, 4] + }, + { + title: 'monday, cross year', + date: [2014, 12, 1], + expected: [2014, 12, 1] + }, + { + title: 'monday, february/march normal year', + date: [2015, 2, 23], + expected: [2015, 2, 23] + }, + { + title: 'monday, february/march step year', + date: [2016, 2, 22], + expected: [2016, 2, 22] + }, + { + title: 'sunday, cross year', + date: [2023, 1, 1], + expected: [2022, 12, 26] + }, + { + title: 'saturday, february/march step year', + date: [2024, 3, 2], + expected: [2024, 2, 26] + } +]; + +/** Returns midnight (local time) for given year/month/date. */ +function createDate([year, month, date]) { + return new Date(year, month - 1, date); +} + +/** Returns midnight (UTC) for given year/month/date. */ +function createUTCDate([year, month, date]) { + return new Date(Date.UTC(year, month - 1, date)); +} + +describe('getMonday', () => { + for (const { date, expected, title } of TEST_CASES) { + const expectedString = createDate(expected).toLocaleString(); + it(`${date} ===> ${expectedString} - ${title}`, () => { + const local = getMonday(createDate(date)); + const localString = new Date(local).toLocaleString(); + expect(localString).toBe(expectedString); + }); + } +}); + +describe('getUTCMonday', () => { + for (const { date, expected, title } of TEST_CASES) { + const expectedString = createUTCDate(expected).toISOString(); + it(`${date} ===> ${expectedString} - ${title}`, () => { + const utc = getUTCMonday(createUTCDate(date)); + const utcString = new Date(utc).toISOString(); + expect(utcString).toBe(expectedString); + }); + } +}); diff --git a/packages/react-core/src/index.d.ts b/packages/react-core/src/index.d.ts index be394717d..7b1ea9085 100644 --- a/packages/react-core/src/index.d.ts +++ b/packages/react-core/src/index.d.ts @@ -7,7 +7,7 @@ export { REQUEST_GET_MAX_URL_LENGTH } from './utils/requestsUtils'; -export { getMonday } from './utils/dateUtils'; +export { getMonday, getUTCMonday } from './utils/dateUtils'; export { InvalidColumnError } from './utils/InvalidColumnError'; diff --git a/packages/react-core/src/index.js b/packages/react-core/src/index.js index 2e31d74bb..754c5acbb 100644 --- a/packages/react-core/src/index.js +++ b/packages/react-core/src/index.js @@ -5,7 +5,7 @@ export { REQUEST_GET_MAX_URL_LENGTH } from './utils/requestsUtils'; -export { getMonday } from './utils/dateUtils'; +export { getMonday, getUTCMonday } from './utils/dateUtils'; export { InvalidColumnError } from './utils/InvalidColumnError'; diff --git a/packages/react-core/src/operations/groupByDate.js b/packages/react-core/src/operations/groupByDate.js index fdeee46df..25e17eaac 100644 --- a/packages/react-core/src/operations/groupByDate.js +++ b/packages/react-core/src/operations/groupByDate.js @@ -1,4 +1,4 @@ -import { getMonday } from '../utils/dateUtils'; +import { getUTCMonday } from '../utils/dateUtils'; import { aggregate, aggregationFunctions } from './aggregation'; import { GroupDateTypes } from './constants/GroupDateTypes'; @@ -6,7 +6,7 @@ const GROUP_KEY_FN_MAPPING = { // @ts-ignore [GroupDateTypes.YEARS]: (date) => Date.UTC(date.getUTCFullYear()), [GroupDateTypes.MONTHS]: (date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth()), - [GroupDateTypes.WEEKS]: (date) => getMonday(date), + [GroupDateTypes.WEEKS]: (date) => getUTCMonday(date), [GroupDateTypes.DAYS]: (date) => Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), [GroupDateTypes.HOURS]: (date) => diff --git a/packages/react-core/src/utils/dateUtils.js b/packages/react-core/src/utils/dateUtils.js index a415f919e..100118791 100644 --- a/packages/react-core/src/utils/dateUtils.js +++ b/packages/react-core/src/utils/dateUtils.js @@ -1,7 +1,24 @@ +/** + * Returns midnight (local time) on the Monday preceeding a given date, in + * milliseconds since the UNIX epoch. + */ export function getMonday(date) { const dateCp = new Date(date); const day = dateCp.getDay(); const diff = dateCp.getDate() - day + (day ? 1 : -6); // adjust when day is sunday dateCp.setDate(diff); + dateCp.setHours(0, 0, 0, 0); + return dateCp.getTime(); +} + +/** + * Returns midnight (UTC) on the Monday preceeding a given date, in + * milliseconds since the UNIX epoch. + */ +export function getUTCMonday(date) { + const dateCp = new Date(date); + const day = dateCp.getUTCDay(); + const diff = dateCp.getUTCDate() - day + (day ? 1 : -6); // adjust when day is sunday + dateCp.setUTCDate(diff); return Date.UTC(dateCp.getUTCFullYear(), dateCp.getUTCMonth(), dateCp.getUTCDate()); } diff --git a/packages/react-ui/__tests__/widgets/utils/timeFormat.test.js b/packages/react-ui/__tests__/widgets/utils/timeFormat.test.js index 8390e3e6f..62fde0eea 100644 --- a/packages/react-ui/__tests__/widgets/utils/timeFormat.test.js +++ b/packages/react-ui/__tests__/widgets/utils/timeFormat.test.js @@ -4,12 +4,19 @@ import { formatBucketRange } from '../../../src/widgets/TimeSeriesWidgetUI/utils describe('timeFormat', () => { describe('formatBucketRange', () => { describe('buckets from date', () => { + /** https://stackoverflow.com/a/33909265 */ + function parseISOLocal(s) { + var b = s.split(/\D/); + return new Date(b[0], b[1] - 1, b[2], b[3] ?? 0, b[4] ?? 0, b[5] ?? 0); + } function defineCases(cases) { cases.forEach(({ date, expected, title, ...params }) => it(`${date} / ${params.stepMultiplier ?? 1} ${ params.stepSize } ===> ${expected} ${title ? `- ${title}` : ''}`, () => - expect(formatBucketRange({ date: new Date(date), ...params })).toBe(expected)) + expect(formatBucketRange({ date: parseISOLocal(date), ...params })).toBe( + expected + )) ); } describe('second', () => {