From df43696b05adc92e80f2fc54d3f1c1ddccf1f108 Mon Sep 17 00:00:00 2001 From: Francis Eytan Dortort Date: Sat, 1 Aug 2026 14:01:52 -0400 Subject: [PATCH] fix(darwin): correct timezone offset across month/year boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeTimezoneOffsetMinutes converted each timezone's wall clock to `day*1440 + h*60 + m` using day-of-month, then subtracted the two. When the current instant lands on different calendar days across a month boundary (e.g. Jul 31 locally but already Aug 1 in Tokyo), day-of-month jumps 31 -> 1 instead of 31 -> 32, injecting a ~30-day (43200-min) error — so the launchd CalendarInterval timezone adjustment was wrong for far-apart timezones around month ends. Use the full year/month/day via Date.UTC so the subtraction is correct across month and year boundaries. Add a regression test pinned (fake timers) to 2026-07-31T23:30Z, the instant that reproduced the failure. Confidence: high Scope-risk: narrow Not-tested: leap-second edge (not representable by Intl parts) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/__tests__/schedulers/darwin.test.ts | 18 +++++++++++++++++- src/schedulers/darwin.ts | 16 +++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/__tests__/schedulers/darwin.test.ts b/src/__tests__/schedulers/darwin.test.ts index b8f26ef..73e355a 100644 --- a/src/__tests__/schedulers/darwin.test.ts +++ b/src/__tests__/schedulers/darwin.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { generatePlist, cronToCalendarInterval, @@ -197,6 +197,22 @@ describe('computeTimezoneOffsetMinutes', () => { expect(tokyoAheadOfNY).toBeGreaterThanOrEqual(13 * 60); expect(tokyoAheadOfNY).toBeLessThanOrEqual(14 * 60); }); + + it('stays correct when the instant straddles a month boundary (regression)', () => { + // At 2026-07-31T23:30Z it is still Jul 31 in UTC/NY but already Aug 1 in Tokyo, + // so a day-of-month-only calc jumps 31 -> 1 and injects a ~30-day error. + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-31T23:30:00Z')); + try { + const toTokyo = computeTimezoneOffsetMinutes('Asia/Tokyo'); + const toNY = computeTimezoneOffsetMinutes('America/New_York'); + const tokyoAheadOfNY = toNY - toTokyo; + expect(tokyoAheadOfNY).toBeGreaterThanOrEqual(13 * 60); + expect(tokyoAheadOfNY).toBeLessThanOrEqual(14 * 60); + } finally { + vi.useRealTimers(); + } + }); }); describe('adjustCalendarIntervalsForTimezone', () => { diff --git a/src/schedulers/darwin.ts b/src/schedulers/darwin.ts index b99c18c..bc9e900 100644 --- a/src/schedulers/darwin.ts +++ b/src/schedulers/darwin.ts @@ -46,15 +46,17 @@ export function computeTimezoneOffsetMinutes(targetTz: string): number { ...(tz ? { timeZone: tz } : {}), }); - const toMinutesSinceEpochDay = (parts: Intl.DateTimeFormatPart[]) => { - const day = parseInt(parts.find(p => p.type === 'day')!.value, 10); - const h = parseInt(parts.find(p => p.type === 'hour')!.value, 10); - const m = parseInt(parts.find(p => p.type === 'minute')!.value, 10); - return day * 1440 + h * 60 + m; + const toWallMinutes = (parts: Intl.DateTimeFormatPart[]) => { + const get = (type: string) => parseInt(parts.find(p => p.type === type)!.value, 10); + // Use the full year/month/day so the subtraction stays correct across month + // and year boundaries (day-of-month alone jumps e.g. 31 -> 1, a ~30-day error). + return Math.round( + Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute')) / 60000, + ); }; - const localMinutes = toMinutesSinceEpochDay(fmt().formatToParts(now)); - const targetMinutes = toMinutesSinceEpochDay(fmt(targetTz).formatToParts(now)); + const localMinutes = toWallMinutes(fmt().formatToParts(now)); + const targetMinutes = toWallMinutes(fmt(targetTz).formatToParts(now)); return localMinutes - targetMinutes; }