Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/__tests__/schedulers/darwin.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
generatePlist,
cronToCalendarInterval,
Expand Down Expand Up @@ -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', () => {
Expand Down
16 changes: 9 additions & 7 deletions src/schedulers/darwin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading