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; }