Skip to content
Open
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
29 changes: 25 additions & 4 deletions src/CronDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const DAYS_IN_MONTH: readonly number[] = Object.freeze([31, 29, 31, 30, 3
export class CronDate {
#date: DateTime;
#dstStart: number | null = null;
#dstStartLandingHour: number | null = null;
#dstEnd: number | null = null;

/**
Expand All @@ -39,6 +40,7 @@ export class CronDate {
} else if (timestamp instanceof CronDate) {
this.#date = timestamp.#date;
this.#dstStart = timestamp.#dstStart;
this.#dstStartLandingHour = timestamp.#dstStartLandingHour;
this.#dstEnd = timestamp.#dstEnd;
} else if (timestamp instanceof Date) {
this.#date = DateTime.fromJSDate(timestamp, dateOpts);
Expand Down Expand Up @@ -81,11 +83,21 @@ export class CronDate {
}

/**
* Sets daylight savings start time.
* Sets daylight savings start time. Clears the landing hour, which is only
* meaningful for a window this class recorded itself.
* @param {number | null} value
*/
set dstStart(value: number | null) {
this.#dstStart = value;
this.#dstStartLandingHour = null;
}

/**
* Returns the first existing hour after the skipped window, or null.
* @returns {number | null}
*/
get dstStartLandingHour(): number | null {
return this.#dstStartLandingHour;
}

/**
Expand Down Expand Up @@ -553,13 +565,22 @@ export class CronDate {
}

const previousHour = this.getHours();
const previousOffset = this.getUTCOffset();
this.invokeDateOperation(op, unit);
const currentHour = this.getHours();
const diff = currentHour - previousHour;
if (diff === 2) {

// Spring-forward is measured from the UTC offset, which rises by exactly
// the time that no longer exists, so it holds for any gap width. The old
// `diff === 2` test only caught one-hour gaps, missing Antarctica/Troll.
const skippedHours = Math.floor((this.getUTCOffset() - previousOffset) / 60);

if (skippedHours >= 1) {
if (hoursLength !== 24) {
// Record the skipped hour during spring-forward transition (previousHour + 1)
this.dstStart = previousHour + 1;
// First skipped hour, and the hour landed on so the window can be
// recognised later. Modulo guards a gap that crosses midnight.
this.#dstStart = (previousHour + 1) % 24;
this.#dstStartLandingHour = currentHour;
}
} else if (diff === 0 && this.getMinutes() === 0 && this.getSeconds() === 0) {
if (hoursLength !== 24) {
Expand Down
12 changes: 8 additions & 4 deletions src/CronExpression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,10 +437,14 @@ export class CronExpression {
const isDstEnd = currentDate.dstEnd === currentHour;

// DST start: if the scheduled hour is skipped (e.g. 03:00 doesn't exist),
// accept the next existing hour when it matches the skipped one.
if (currentDate.dstStart !== null && currentDate.dstStart === currentHour - 1) {
if (CronExpression.#matchSchedule(currentDate.dstStart, hourValues)) {
return true;
// accept the hour the clock landed on when it matches a skipped one. A gap
// can span several hours (Antarctica/Troll skips both 01:00 and 02:00), and
// is confined to the landing hour so it ends once the clock moves on.
if (currentDate.dstStart !== null && currentDate.dstStartLandingHour === currentHour) {
for (let skipped = currentDate.dstStart; skipped < currentHour; skipped++) {
if (CronExpression.#matchSchedule(skipped, hourValues)) {
return true;
}
}
}

Expand Down
83 changes: 83 additions & 0 deletions tests/CronExpressionParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,89 @@ describe('CronExpressionParser', () => {
expect(prev).toBeInstanceOf(CronDate);
expect(prev.toISOString()).toEqual('2026-03-08T13:00:00.000Z'); // 8am CDT Sun Mar 8
});

// Antarctica/Troll advances two hours on 2026-03-29, 00:00 -> 03:00, so
// both 01:00 and 02:00 are skipped. A schedule inside that window used to
// be dropped for the day rather than moved to the far side of the gap.
describe('transitions wider than one hour', () => {
test('compensates a schedule inside a two-hour gap instead of skipping the day', () => {
const options: CronExpressionOptions = {
currentDate: new Date('2026-03-27T12:00:00.000Z'),
tz: 'Antarctica/Troll',
};

const interval = CronExpressionParser.parse('30 1 * * *', options);

expect(interval.next().toISOString()).toEqual('2026-03-28T01:30:00.000Z'); // 01:30 at UTC+00

// 01:30 does not exist on the 29th, so it lands on the first instant
// that does, the same compensation a one-hour zone already performs.
expect(interval.next().toISOString()).toEqual('2026-03-29T01:30:00.000Z'); // 03:30 local

expect(interval.next().toISOString()).toEqual('2026-03-29T23:30:00.000Z'); // 01:30 at UTC+02
});

test('compensates the second skipped hour of a two-hour gap', () => {
const options: CronExpressionOptions = {
currentDate: new Date('2026-03-28T12:00:00.000Z'),
tz: 'Antarctica/Troll',
};

// 02:00 is the second of the two hours the transition removes.
const interval = CronExpressionParser.parse('30 2 * * *', options);

expect(interval.next().toISOString()).toEqual('2026-03-29T01:30:00.000Z'); // 03:30 local
expect(interval.next().toISOString()).toEqual('2026-03-30T00:30:00.000Z'); // 02:30 local
});

test('does not repeat the compensated occurrence on later hours', () => {
const options: CronExpressionOptions = {
currentDate: new Date('2026-03-28T12:00:00.000Z'),
tz: 'Antarctica/Troll',
};

const interval = CronExpressionParser.parse('30 1 * * *', options);

// Matching has to stop once the search leaves the window, or every
// later hour of the day matches a skipped one as well.
expect(interval.take(3).map((date) => date.toISOString())).toEqual([
'2026-03-29T01:30:00.000Z', // 03:30 local, compensated
'2026-03-29T23:30:00.000Z', // 01:30 local on the 30th
'2026-03-30T23:30:00.000Z', // 01:30 local on the 31st
]);
});

test('does not repeat the compensated occurrence when the minute rolls the hour', () => {
// The window is left by a minute step rather than an hour step: minute
// 59 rolls into the next hour through #moveToNextSecond.
const options: CronExpressionOptions = {
currentDate: new Date('2026-03-07T12:00:00.000Z'),
tz: 'America/New_York',
};

const interval = CronExpressionParser.parse('30 59 2 * * *', options);

expect(interval.take(3).map((date) => date.toISOString())).toEqual([
'2026-03-08T07:59:30.000Z', // 03:59:30 EDT, compensated
'2026-03-09T06:59:30.000Z', // 02:59:30 EDT
'2026-03-10T06:59:30.000Z',
]);
});

test('leaves a sub-hour transition alone', () => {
// Australia/Lord_Howe moves by 30 minutes, so no whole wall-clock hour
// is removed and nothing should be compensated.
const options: CronExpressionOptions = {
currentDate: new Date('2026-10-03T05:00:00.000Z'),
tz: 'Australia/Lord_Howe',
};

const interval = CronExpressionParser.parse('30 2 * * *', options);

expect(interval.next().toISOString()).toEqual('2026-10-03T15:30:00.000Z');
expect(interval.next().toISOString()).toEqual('2026-10-04T15:30:00.000Z');
});
});
});

describe('test expressions with "L" last of flag', () => {
Expand Down
Loading