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
90 changes: 90 additions & 0 deletions packages/core/src/__tests__/scheduled-task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { describe, it } from 'node:test';
import { markPersisted } from '../persisted-value.js';
import {
Expand Down Expand Up @@ -181,6 +182,95 @@ describe('scheduled-task catalog', () => {
assert.equal(new Date(february!).getDate(), 28);
});

it('reconstructs daily and weekly wall times after a DST gap', () => {
const moduleUrl = new URL('../scheduled-task.js', import.meta.url).href;
const cases = [
{
timezone: 'America/New_York',
anchor: '2026-03-01T02:30:15.125-05:00',
after: '2026-03-08T04:00:00-04:00',
daily: '2026-03-09T02:30:15.125-04:00',
weekly: '2026-03-15T02:30:15.125-04:00',
},
{
timezone: 'America/New_York',
anchor: '2026-03-03T02:30:15.125-05:00',
after: '2026-03-08T00:00:00-05:00',
daily: '2026-03-08T03:30:15.125-04:00',
weekly: '2026-03-10T02:30:15.125-04:00',
},
{
timezone: 'Australia/Lord_Howe',
anchor: '2026-09-27T02:15:15.125+10:30',
after: '2026-10-04T04:00:00+11:00',
daily: '2026-10-05T02:15:15.125+11:00',
weekly: '2026-10-11T02:15:15.125+11:00',
},
{
timezone: 'America/New_York',
anchor: '2026-10-25T01:30:15.125-04:00',
after: '2026-11-01T01:30:15.125-05:00',
daily: '2026-11-02T01:30:15.125-05:00',
weekly: '2026-11-08T01:30:15.125-05:00',
},
];
for (const scenario of cases) {
const script = `
import { computeNextFireAt } from ${JSON.stringify(moduleUrl)};
const scenario = ${JSON.stringify(scenario)};
const result = ['daily', 'weekly'].map((recurrence) =>
computeNextFireAt({
kind: 'calendar', recurrence, anchorAt: Date.parse(scenario.anchor),
}, Date.parse(scenario.after)));
process.stdout.write(JSON.stringify(result));
`;
const actual = JSON.parse(
execFileSync(process.execPath, ['--input-type=module', '-e', script], {
env: { ...process.env, TZ: scenario.timezone },
encoding: 'utf8',
}),
);
assert.deepEqual(
actual,
[Date.parse(scenario.daily), Date.parse(scenario.weekly)],
`${scenario.timezone}: ${scenario.after}`,
);
}
});

it('returns to the anchor wall time after firing on a DST gap day', () => {
const moduleUrl = new URL('../scheduled-task.js', import.meta.url).href;
const script = `
import { computeNextFireAt } from ${JSON.stringify(moduleUrl)};
const anchors = {
daily: '2026-03-01T02:30:15.125-05:00',
weekly: '2026-03-01T02:30:15.125-05:00',
monthly: '2026-01-08T02:30:15.125-05:00',
};
const result = Object.entries(anchors).map(([recurrence, anchor]) => {
const schedule = { kind: 'calendar', recurrence, anchorAt: Date.parse(anchor) };
let after = Date.parse('2026-03-08T00:00:00-05:00');
return Array.from({length: 2}, () => {
after = computeNextFireAt(schedule, after);
return after;
});
});
process.stdout.write(JSON.stringify(result));
`;
const actual = JSON.parse(
execFileSync(process.execPath, ['--input-type=module', '-e', script], {
env: { ...process.env, TZ: 'America/New_York' },
encoding: 'utf8',
}),
);
const gap = Date.parse('2026-03-08T03:30:15.125-04:00');
assert.deepEqual(actual, [
[gap, Date.parse('2026-03-09T02:30:15.125-04:00')],
[gap, Date.parse('2026-03-15T02:30:15.125-04:00')],
[gap, Date.parse('2026-04-08T02:30:15.125-04:00')],
]);
});

it('rejects tasks whose first fire is not before expiration', () => {
const now = Date.UTC(2026, 0, 5, 8, 0, 0);
const result = normalizeCreateScheduledTaskInput(
Expand Down
47 changes: 24 additions & 23 deletions packages/core/src/scheduled-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,34 +616,35 @@ function asFiniteNumber(value: unknown): number | null {
return null;
}

/**
* Calendar recurrence uses Date's local-time rules: shift gaps forward and take
* the first repeated time. Rebuild from the anchor so gap shifts do not carry
* into later cycles; cron instead skips nonexistent local times.
*/
function nextCalendarFireAt(
schedule: Extract<ScheduledTaskSchedule, { kind: 'calendar' }>,
after: number,
): number {
const anchor = new Date(schedule.anchorAt);
if (schedule.recurrence === 'daily') {
const next = new Date(after);
next.setHours(
anchor.getHours(),
anchor.getMinutes(),
anchor.getSeconds(),
anchor.getMilliseconds(),
);
if (next.getTime() <= after) next.setDate(next.getDate() + 1);
return next.getTime();
}
if (schedule.recurrence === 'weekly') {
const next = new Date(after);
next.setHours(
anchor.getHours(),
anchor.getMinutes(),
anchor.getSeconds(),
anchor.getMilliseconds(),
);
const dayDelta = (anchor.getDay() - next.getDay() + 7) % 7;
next.setDate(next.getDate() + dayDelta);
if (next.getTime() <= after) next.setDate(next.getDate() + 7);
return next.getTime();
if (schedule.recurrence === 'daily' || schedule.recurrence === 'weekly') {
const base = new Date(after);
const dayOffset =
schedule.recurrence === 'daily' ? 0 : (anchor.getDay() - base.getDay() + 7) % 7;
const periodDays = schedule.recurrence === 'daily' ? 1 : 7;
// Build each occurrence from the anchor: a DST gap may normalize today's
// wall time, but that adjusted hour must not carry into another date.
const occurrence = (offset: number): number =>
new Date(
base.getFullYear(),
base.getMonth(),
base.getDate() + offset,
anchor.getHours(),
anchor.getMinutes(),
anchor.getSeconds(),
anchor.getMilliseconds(),
).getTime();
const next = occurrence(dayOffset);
return next > after ? next : occurrence(dayOffset + periodDays);
}
const afterDate = new Date(after);
for (let offset = 0; offset <= 480; offset += 1) {
Expand Down