From 130173fa3f046f4bff08cc893d2a4897b6654fc7 Mon Sep 17 00:00:00 2001 From: Mayank Dhingra Date: Mon, 26 Oct 2026 09:56:00 -0400 Subject: [PATCH 1/2] Fix arithmetic with UTC timezone objects Use the normalized timezone name when selecting UTC calendar setters. This keeps UTC objects consistent with UTC strings for days, month-end clamping, and leap-year arithmetic. Add isolated host-timezone regressions for DST, subtraction, UTC casing, immutability, and non-UTC object controls. Signed-off-by: Mayank Dhingra --- src/addDays.ts | 2 +- src/addMonths.ts | 2 +- tests/utc-timezone-object.spec.ts | 86 +++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tests/utc-timezone-object.spec.ts diff --git a/src/addDays.ts b/src/addDays.ts index bc2aa57..facfb08 100644 --- a/src/addDays.ts +++ b/src/addDays.ts @@ -16,7 +16,7 @@ export function addDays(dateObj: Date, days: number, timeZone?: TimeZone | strin const d = new Date(dateObj.getTime()); // Handle UTC calculation - if (isUTC(timeZone)) { + if (isUTC(zoneName)) { d.setUTCDate(d.getUTCDate() + days); return d; } diff --git a/src/addMonths.ts b/src/addMonths.ts index b50610f..29bca1c 100644 --- a/src/addMonths.ts +++ b/src/addMonths.ts @@ -16,7 +16,7 @@ export function addMonths(dateObj: Date, months: number, timeZone?: TimeZone | s const d = new Date(dateObj.getTime()); // Handle UTC calculation - if (isUTC(timeZone)) { + if (isUTC(zoneName)) { d.setUTCMonth(d.getUTCMonth() + months); // Adjust to last day of month if new month has fewer days if (d.getUTCDate() < dateObj.getUTCDate()) { diff --git a/tests/utc-timezone-object.spec.ts b/tests/utc-timezone-object.spec.ts new file mode 100644 index 0000000..460bbba --- /dev/null +++ b/tests/utc-timezone-object.spec.ts @@ -0,0 +1,86 @@ +import { spawnSync } from 'node:child_process'; +import { expect, test } from 'vitest'; + +type Case = [method: string, input: string, amount: number, expected: string]; + +const utcCases: Case[] = [ + ['addDays', '2025-03-08T12:00:00.000Z', 1, '2025-03-09T12:00:00.000Z'], + ['addDays', '2025-03-09T12:00:00.000Z', -1, '2025-03-08T12:00:00.000Z'], + ['addDays', '2025-11-01T12:00:00.000Z', 1, '2025-11-02T12:00:00.000Z'], + ['addDays', '2025-11-02T12:00:00.000Z', -1, '2025-11-01T12:00:00.000Z'], + ['addDays', '2025-03-29T12:00:00.000Z', 1, '2025-03-30T12:00:00.000Z'], + ['addDays', '2025-10-26T12:00:00.000Z', -1, '2025-10-25T12:00:00.000Z'], + ['addMonths', '2025-01-31T00:30:00.000Z', 1, '2025-02-28T00:30:00.000Z'], + ['addMonths', '2025-01-30T23:30:00.000Z', 1, '2025-02-28T23:30:00.000Z'], + ['addMonths', '2025-03-31T00:30:00.000Z', -1, '2025-02-28T00:30:00.000Z'], + ['addMonths', '2025-02-28T12:00:00.000Z', 1, '2025-03-28T12:00:00.000Z'], + ['addMonths', '2025-11-30T12:00:00.000Z', -1, '2025-10-30T12:00:00.000Z'], + ['addYears', '2024-02-29T00:30:00.000Z', 1, '2025-02-28T00:30:00.000Z'], + ['addYears', '2024-02-28T23:30:00.000Z', 1, '2025-02-28T23:30:00.000Z'], + ['addYears', '2024-02-29T00:30:00.000Z', -1, '2023-02-28T00:30:00.000Z'], + ['addYears', '2024-03-09T12:00:00.000Z', 1, '2025-03-09T12:00:00.000Z'] +]; + +const losAngelesCases: Case[] = [ + ['addDays', '2025-03-08T20:00:00.000Z', 1, '2025-03-09T19:00:00.000Z'], + ['addMonths', '2025-02-28T20:00:00.000Z', 1, '2025-03-28T19:00:00.000Z'], + ['addYears', '2024-03-09T20:00:00.000Z', 1, '2025-03-09T19:00:00.000Z'] +]; + +const utcZones = ['UTC', 'utc', 'uTc'].flatMap((zone_name) => [ + zone_name, + { zone_name, gmt_offset: [0] } +]); + +const program = ` + import * as date from ${JSON.stringify(new URL('../src/index.ts', import.meta.url).href)}; + import Los_Angeles from ${JSON.stringify(new URL('../src/timezones/America/Los_Angeles.ts', import.meta.url).href)}; + + const run = (cases, zones) => cases.flatMap(([method, input, amount]) => + zones.map(zone => { + const original = new Date(input); + const result = date[method](original, amount, zone); + return { + actual: result.toISOString(), + original: original.toISOString(), + distinct: result !== original + }; + }) + ); + + console.log(JSON.stringify([ + ...run(${JSON.stringify(utcCases)}, ${JSON.stringify(utcZones)}), + ...run(${JSON.stringify(losAngelesCases)}, ['America/Los_Angeles', Los_Angeles]) + ])); +`; + +interface Result { + actual: string; + original: string; + distinct: boolean; +} + +// Set TZ only in child processes, independently of other tests' local time zones. +test.each(['America/New_York', 'America/Los_Angeles', 'Europe/Berlin', 'Asia/Tokyo', 'UTC'])( + 'UTC objects and names use the same calendar arithmetic on a %s host', + (hostZone) => { + const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type', 'module', '--eval', program], { + env: { ...process.env, TZ: hostZone }, + encoding: 'utf8' + }); + + expect(result.status, result.stderr).toBe(0); + + const actual = JSON.parse(result.stdout) as Result[]; + const expected = [ + ...utcCases.flatMap(([, input, , value]) => utcZones.map(() => ({ + actual: value, original: input, distinct: true + }))), + ...losAngelesCases.flatMap(([, input, , value]) => Array.from({ length: 2 }, () => ({ + actual: value, original: input, distinct: true + }))) + ]; + + expect(actual).toEqual(expected); + } +); From fdd19afc28ed4e0dcd2435678da1095389d7a550 Mon Sep 17 00:00:00 2001 From: Mayank Dhingra Date: Tue, 27 Oct 2026 10:26:30 -0400 Subject: [PATCH 2/2] test: align UTC timezone object cases with existing specs Move one regression per arithmetic function into its existing UTC Time block and select the host timezone with the usual beforeAll convention. Cover daylight-saving, month-end and leap-day arithmetic. Signed-off-by: Mayank Dhingra --- tests/addDays.spec.ts | 9 +++- tests/addMonths.spec.ts | 9 +++- tests/addYears.spec.ts | 9 +++- tests/utc-timezone-object.spec.ts | 86 ------------------------------- 4 files changed, 24 insertions(+), 89 deletions(-) delete mode 100644 tests/utc-timezone-object.spec.ts diff --git a/tests/addDays.spec.ts b/tests/addDays.spec.ts index fae9bc5..49bb138 100644 --- a/tests/addDays.spec.ts +++ b/tests/addDays.spec.ts @@ -112,7 +112,14 @@ describe('Local Time', () => { }); describe('UTC Time', () => { - beforeAll(() => (process.env.TZ = 'Asia/Tokyo')); + beforeAll(() => (process.env.TZ = 'America/Los_Angeles')); + + test('UTC timezone object across the daylight-saving transition', () => { + const date1 = new Date(Date.UTC(2024, 2, 9, 12)); + const date2 = new Date(Date.UTC(2024, 2, 10, 12)); + + expect(addDays(date1, 1, { zone_name: 'UTC', gmt_offset: [0] })).toEqual(date2); + }); test('One day after 2:00 AM on January 31, 2024', () => { const date1 = new Date(Date.UTC(2024, 0, 31, 10)); diff --git a/tests/addMonths.spec.ts b/tests/addMonths.spec.ts index f492d68..7bc6198 100644 --- a/tests/addMonths.spec.ts +++ b/tests/addMonths.spec.ts @@ -112,7 +112,14 @@ describe('Local Time', () => { }); describe('UTC Time', () => { - beforeAll(() => (process.env.TZ = 'Asia/Tokyo')); + beforeAll(() => (process.env.TZ = 'America/Los_Angeles')); + + test('UTC timezone object at the end of January', () => { + const date1 = new Date(Date.UTC(2025, 0, 31, 0, 30)); + const date2 = new Date(Date.UTC(2025, 1, 28, 0, 30)); + + expect(addMonths(date1, 1, { zone_name: 'UTC', gmt_offset: [0] })).toEqual(date2); + }); test('One month after 2:00 AM on January 31, 2024', () => { const date1 = new Date(Date.UTC(2024, 0, 31, 10)); diff --git a/tests/addYears.spec.ts b/tests/addYears.spec.ts index df07bcc..84868c2 100644 --- a/tests/addYears.spec.ts +++ b/tests/addYears.spec.ts @@ -112,7 +112,14 @@ describe('Local Time', () => { }); describe('UTC Time', () => { - beforeAll(() => (process.env.TZ = 'Asia/Tokyo')); + beforeAll(() => (process.env.TZ = 'America/Los_Angeles')); + + test('UTC timezone object when clamping a leap day', () => { + const date1 = new Date(Date.UTC(2024, 1, 29, 0, 30)); + const date2 = new Date(Date.UTC(2025, 1, 28, 0, 30)); + + expect(addYears(date1, 1, { zone_name: 'UTC', gmt_offset: [0] })).toEqual(date2); + }); test('One year after 2:00 AM on January 31, 2024', () => { const date1 = new Date(Date.UTC(2024, 0, 31, 10)); diff --git a/tests/utc-timezone-object.spec.ts b/tests/utc-timezone-object.spec.ts deleted file mode 100644 index 460bbba..0000000 --- a/tests/utc-timezone-object.spec.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import { expect, test } from 'vitest'; - -type Case = [method: string, input: string, amount: number, expected: string]; - -const utcCases: Case[] = [ - ['addDays', '2025-03-08T12:00:00.000Z', 1, '2025-03-09T12:00:00.000Z'], - ['addDays', '2025-03-09T12:00:00.000Z', -1, '2025-03-08T12:00:00.000Z'], - ['addDays', '2025-11-01T12:00:00.000Z', 1, '2025-11-02T12:00:00.000Z'], - ['addDays', '2025-11-02T12:00:00.000Z', -1, '2025-11-01T12:00:00.000Z'], - ['addDays', '2025-03-29T12:00:00.000Z', 1, '2025-03-30T12:00:00.000Z'], - ['addDays', '2025-10-26T12:00:00.000Z', -1, '2025-10-25T12:00:00.000Z'], - ['addMonths', '2025-01-31T00:30:00.000Z', 1, '2025-02-28T00:30:00.000Z'], - ['addMonths', '2025-01-30T23:30:00.000Z', 1, '2025-02-28T23:30:00.000Z'], - ['addMonths', '2025-03-31T00:30:00.000Z', -1, '2025-02-28T00:30:00.000Z'], - ['addMonths', '2025-02-28T12:00:00.000Z', 1, '2025-03-28T12:00:00.000Z'], - ['addMonths', '2025-11-30T12:00:00.000Z', -1, '2025-10-30T12:00:00.000Z'], - ['addYears', '2024-02-29T00:30:00.000Z', 1, '2025-02-28T00:30:00.000Z'], - ['addYears', '2024-02-28T23:30:00.000Z', 1, '2025-02-28T23:30:00.000Z'], - ['addYears', '2024-02-29T00:30:00.000Z', -1, '2023-02-28T00:30:00.000Z'], - ['addYears', '2024-03-09T12:00:00.000Z', 1, '2025-03-09T12:00:00.000Z'] -]; - -const losAngelesCases: Case[] = [ - ['addDays', '2025-03-08T20:00:00.000Z', 1, '2025-03-09T19:00:00.000Z'], - ['addMonths', '2025-02-28T20:00:00.000Z', 1, '2025-03-28T19:00:00.000Z'], - ['addYears', '2024-03-09T20:00:00.000Z', 1, '2025-03-09T19:00:00.000Z'] -]; - -const utcZones = ['UTC', 'utc', 'uTc'].flatMap((zone_name) => [ - zone_name, - { zone_name, gmt_offset: [0] } -]); - -const program = ` - import * as date from ${JSON.stringify(new URL('../src/index.ts', import.meta.url).href)}; - import Los_Angeles from ${JSON.stringify(new URL('../src/timezones/America/Los_Angeles.ts', import.meta.url).href)}; - - const run = (cases, zones) => cases.flatMap(([method, input, amount]) => - zones.map(zone => { - const original = new Date(input); - const result = date[method](original, amount, zone); - return { - actual: result.toISOString(), - original: original.toISOString(), - distinct: result !== original - }; - }) - ); - - console.log(JSON.stringify([ - ...run(${JSON.stringify(utcCases)}, ${JSON.stringify(utcZones)}), - ...run(${JSON.stringify(losAngelesCases)}, ['America/Los_Angeles', Los_Angeles]) - ])); -`; - -interface Result { - actual: string; - original: string; - distinct: boolean; -} - -// Set TZ only in child processes, independently of other tests' local time zones. -test.each(['America/New_York', 'America/Los_Angeles', 'Europe/Berlin', 'Asia/Tokyo', 'UTC'])( - 'UTC objects and names use the same calendar arithmetic on a %s host', - (hostZone) => { - const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type', 'module', '--eval', program], { - env: { ...process.env, TZ: hostZone }, - encoding: 'utf8' - }); - - expect(result.status, result.stderr).toBe(0); - - const actual = JSON.parse(result.stdout) as Result[]; - const expected = [ - ...utcCases.flatMap(([, input, , value]) => utcZones.map(() => ({ - actual: value, original: input, distinct: true - }))), - ...losAngelesCases.flatMap(([, input, , value]) => Array.from({ length: 2 }, () => ({ - actual: value, original: input, distinct: true - }))) - ]; - - expect(actual).toEqual(expected); - } -);