diff --git a/src/CronDate.ts b/src/CronDate.ts index 8c5b1c8..8b1a078 100644 --- a/src/CronDate.ts +++ b/src/CronDate.ts @@ -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; /** @@ -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); @@ -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; } /** @@ -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) { diff --git a/src/CronExpression.ts b/src/CronExpression.ts index 4c89cd6..0784967 100644 --- a/src/CronExpression.ts +++ b/src/CronExpression.ts @@ -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; + } } } diff --git a/tests/CronExpressionParser.test.ts b/tests/CronExpressionParser.test.ts index 976a8b3..848a27c 100644 --- a/tests/CronExpressionParser.test.ts +++ b/tests/CronExpressionParser.test.ts @@ -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', () => {