From 16288e63432bbf56ae97aa21b4f38c9bd6602aa4 Mon Sep 17 00:00:00 2001 From: Aniket Date: Mon, 3 Aug 2026 02:56:09 +0530 Subject: [PATCH 1/2] fix: compensate DST transitions wider than one hour `applyDateOperation` detects a spring-forward by comparing wall-clock hours and testing for a delta of exactly 2, which assumes the gap is one hour wide. `Antarctica/Troll` advances two hours on 2026-03-29, 00:00 to 03:00, so the delta is 3, the branch never fires, and `dstStart` stays null. A schedule inside that window is then dropped for the day rather than moved past the gap. Two changes are needed. Detection now derives the gap from the change in UTC offset, which is exact for any width. A sub-hour transition such as `Australia/Lord_Howe` skips no whole wall-clock hour and so still records nothing, as before. Matching now considers every skipped hour rather than only the one immediately before the current one, since Troll skips both 01:00 and 02:00. For a one-hour gap the loop runs exactly once and behaves as it did. That second change removes an accidental property of the old test: `dstStart === currentHour - 1` stopped matching by itself once the hour advanced, whereas a multi-hour window does not. Without closing the window explicitly, `30 1 * * *` in Troll would match 03:30, then 04:30, then 05:30. The window is closed on hour steps only, because the minutes within the compensating hour still have to match the skipped hour, which is what keeps `*/20 3 * * *` firing at 04:00, 04:20 and 04:40 in the existing Athens test. Result for `30 1 * * *` in `Antarctica/Troll`: before 2026-03-28 01:30 2026-03-30 01:30 <- 29 March never fires after 2026-03-28 01:30 2026-03-29 03:30 2026-03-30 01:30 which is the same compensation `America/New_York` already performs for its one-hour gap. Verified by sweeping 5,865 combinations of 23 expressions across 17 timezones and 15 starting instants, in both directions, against master. 23 cases change, all of them in `Antarctica/Troll`, the only zone in the set with a two-hour gap. Nothing moves in the other 16, including Athens, Lord Howe, Chatham, Havana, Tehran and the fall-back cases. Four regression tests are added in the style of the existing DST block. Three fail without this change; the fourth pins Lord Howe's sub-hour behaviour so a later change cannot start compensating a gap that skips no whole hour. Out of scope: `America/Santiago` transitions at midnight and drops an occurrence the same way, but through a different route, because day-granularity jumps return from `applyDateOperation` before any DST detection runs. That is left separate rather than widening this change. Fixes #419 --- src/CronDate.ts | 34 +++++++++++++-- src/CronExpression.ts | 13 ++++-- tests/CronExpressionParser.test.ts | 70 ++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/CronDate.ts b/src/CronDate.ts index 8c5b1c8..8d6b021 100644 --- a/src/CronDate.ts +++ b/src/CronDate.ts @@ -553,18 +553,46 @@ 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 detected from the change in UTC offset rather than from + // the wall-clock hour delta. + // + // `diff === 2` assumes the gap is exactly one hour wide. `Antarctica/Troll` + // advances two hours (00:00 -> 03:00), giving `diff === 3`, so the branch + // never fires and the skipped hours are never recorded. The offset is exact + // for any width: it rises by precisely the amount of wall-clock time that + // no longer exists. + // + // A sub-hour gap such as `Australia/Lord_Howe`'s 30 minutes skips no whole + // wall-clock hour, so it records nothing, as before. + 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; + // The first wall-clock hour that was skipped. The modulo is defensive: + // no current zone transitions across midnight by whole hours through + // this path, but it keeps the value a valid hour if one ever does. + this.dstStart = (previousHour + 1) % 24; } } else if (diff === 0 && this.getMinutes() === 0 && this.getSeconds() === 0) { if (hoursLength !== 24) { this.dstEnd = currentHour; } + } else if (unit === TimeUnit.Hour) { + // Stepping to an hour that is not the far side of a gap means the search + // has left the skipped window, so the record no longer applies. + // + // The previous `dstStart === currentHour - 1` test was self-limiting and + // needed no such reset: it stopped matching as soon as the hour advanced + // past the single skipped one. Matching a window of several hours is not + // self-limiting, so the window is closed explicitly here. Only hour steps + // clear it, because the minutes and seconds within the compensating hour + // still have to match against the skipped hour. + this.dstStart = null; } } diff --git a/src/CronExpression.ts b/src/CronExpression.ts index 4c89cd6..ed8be44 100644 --- a/src/CronExpression.ts +++ b/src/CronExpression.ts @@ -438,9 +438,16 @@ export class CronExpression { // 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; + // + // A transition can skip more than one hour, so every skipped hour is + // considered, not only the one immediately before the current one: + // `Antarctica/Troll` goes 00:00 -> 03:00 and skips both 01:00 and 02:00. + // For a one-hour gap this loop runs exactly once and behaves as before. + if (currentDate.dstStart !== null) { + 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..99b853c 100644 --- a/tests/CronExpressionParser.test.ts +++ b/tests/CronExpressionParser.test.ts @@ -1684,6 +1684,76 @@ 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); + + // The day before the transition is unaffected: 01:30 at UTC+00. + expect(interval.next().toISOString()).toEqual('2026-03-28T01:30:00.000Z'); + + // 01:30 does not exist on the 29th, so the occurrence lands on the + // first instant that does: 03:30 at UTC+02, the same compensation a + // one-hour zone already performs. + expect(interval.next().toISOString()).toEqual('2026-03-29T01:30:00.000Z'); + + // Back to the scheduled time, now at UTC+02. + expect(interval.next().toISOString()).toEqual('2026-03-29T23:30:00.000Z'); + }); + + 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 a window of several skipped hours has to stop once the + // search leaves that window, or every later hour of the day matches + // the 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('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', () => { From 1daf0c5d753dca61c050d96e0bce94f2f91649b7 Mon Sep 17 00:00:00 2001 From: Aniket Date: Fri, 7 Aug 2026 11:51:35 +0530 Subject: [PATCH 2/2] fix: confine spring-forward compensation to the landing hour Closing the skipped-hour window on hour-unit steps leaked through `#moveToNextSecond`, which rolls minute 59 into the next hour with a minute-unit step. The marker survived that crossing, so the acceptance loop re-matched every later hour of the day. `CronDate` now records the hour the clock landed on alongside `dstStart`, copied and cleared with it, and `#matchHour` accepts a skipped hour only while the clock is still on that landing hour. The window is then self limiting again, as `dstStart === currentHour - 1` was before, so the explicit close on hour steps is gone. Adds a regression test for the reported case, `30 59 2 * * *` in America/New_York, which now yields one occurrence per day again. --- src/CronDate.ts | 47 +++++++++++++----------------- src/CronExpression.ts | 11 +++---- tests/CronExpressionParser.test.ts | 35 +++++++++++++++------- 3 files changed, 48 insertions(+), 45 deletions(-) diff --git a/src/CronDate.ts b/src/CronDate.ts index 8d6b021..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; } /** @@ -558,41 +570,22 @@ export class CronDate { const currentHour = this.getHours(); const diff = currentHour - previousHour; - // Spring-forward is detected from the change in UTC offset rather than from - // the wall-clock hour delta. - // - // `diff === 2` assumes the gap is exactly one hour wide. `Antarctica/Troll` - // advances two hours (00:00 -> 03:00), giving `diff === 3`, so the branch - // never fires and the skipped hours are never recorded. The offset is exact - // for any width: it rises by precisely the amount of wall-clock time that - // no longer exists. - // - // A sub-hour gap such as `Australia/Lord_Howe`'s 30 minutes skips no whole - // wall-clock hour, so it records nothing, as before. + // 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) { - // The first wall-clock hour that was skipped. The modulo is defensive: - // no current zone transitions across midnight by whole hours through - // this path, but it keeps the value a valid hour if one ever does. - this.dstStart = (previousHour + 1) % 24; + // 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) { this.dstEnd = currentHour; } - } else if (unit === TimeUnit.Hour) { - // Stepping to an hour that is not the far side of a gap means the search - // has left the skipped window, so the record no longer applies. - // - // The previous `dstStart === currentHour - 1` test was self-limiting and - // needed no such reset: it stopped matching as soon as the hour advanced - // past the single skipped one. Matching a window of several hours is not - // self-limiting, so the window is closed explicitly here. Only hour steps - // clear it, because the minutes and seconds within the compensating hour - // still have to match against the skipped hour. - this.dstStart = null; } } diff --git a/src/CronExpression.ts b/src/CronExpression.ts index ed8be44..0784967 100644 --- a/src/CronExpression.ts +++ b/src/CronExpression.ts @@ -437,13 +437,10 @@ 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. - // - // A transition can skip more than one hour, so every skipped hour is - // considered, not only the one immediately before the current one: - // `Antarctica/Troll` goes 00:00 -> 03:00 and skips both 01:00 and 02:00. - // For a one-hour gap this loop runs exactly once and behaves as before. - if (currentDate.dstStart !== null) { + // 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 99b853c..848a27c 100644 --- a/tests/CronExpressionParser.test.ts +++ b/tests/CronExpressionParser.test.ts @@ -1697,16 +1697,13 @@ describe('CronExpressionParser', () => { const interval = CronExpressionParser.parse('30 1 * * *', options); - // The day before the transition is unaffected: 01:30 at UTC+00. - expect(interval.next().toISOString()).toEqual('2026-03-28T01:30:00.000Z'); + 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 the occurrence lands on the - // first instant that does: 03:30 at UTC+02, the same compensation a - // one-hour zone already performs. - expect(interval.next().toISOString()).toEqual('2026-03-29T01:30:00.000Z'); + // 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 - // Back to the scheduled time, now at UTC+02. - expect(interval.next().toISOString()).toEqual('2026-03-29T23:30:00.000Z'); + 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', () => { @@ -1730,9 +1727,8 @@ describe('CronExpressionParser', () => { const interval = CronExpressionParser.parse('30 1 * * *', options); - // Matching a window of several skipped hours has to stop once the - // search leaves that window, or every later hour of the day matches - // the skipped one as well. + // 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 @@ -1740,6 +1736,23 @@ describe('CronExpressionParser', () => { ]); }); + 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.