From 55074420dfb414c88f9d81d514663cc4923d5524 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Mon, 22 Jun 2026 14:30:44 +0200 Subject: [PATCH] Fix `Cron.next` skipping days when overflowing --- .changeset/fix-cron-next-missing-day-overflow.md | 5 +++++ packages/effect/src/Cron.ts | 4 ++++ packages/effect/test/Cron.test.ts | 15 +++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 .changeset/fix-cron-next-missing-day-overflow.md diff --git a/.changeset/fix-cron-next-missing-day-overflow.md b/.changeset/fix-cron-next-missing-day-overflow.md new file mode 100644 index 0000000000..6b392d177f --- /dev/null +++ b/.changeset/fix-cron-next-missing-day-overflow.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Fix `Cron.next` skipping earlier matching days when the upcoming day-of-month does not exist in the current month. diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 2b631d2b89..4c6b6dc6f7 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -868,6 +868,10 @@ const stepCron = (cron: Cron, now: DateTime.DateTime.Input | undefined, directio } else { b = daysInMonth(current) - currentDay + boundary.day } + } else if (!reverse && nextDay > daysInMonth(current)) { + // The next matching day does not exist in the current month. Setting it + // directly would overflow and skip earlier matching days next month. + b = daysInMonth(current) - currentDay + boundary.day } else { b = nextDay - currentDay } diff --git a/packages/effect/test/Cron.test.ts b/packages/effect/test/Cron.test.ts index 55a69a8303..ba553c9f4a 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -169,6 +169,21 @@ describe("Cron", () => { deepStrictEqual(next(Cron.parseUnsafe("5 0 8 2 *", london), after), DateTime.toDateUtc(amsterdamTime)) }) + it("next does not skip earlier days when the upcoming day is missing from the month", () => { + const tz = DateTime.zoneMakeNamedUnsafe("UTC") + const cron = Cron.parseUnsafe("0 0 1,16,31 * *", tz) + // From Feb 18 the next matching day in February would be the 31st, which does + // not exist. Rolling onto it must not overshoot past March 1 (also a match). + deepStrictEqual(next(cron, new Date("2020-02-18T00:00:00.000Z")), new Date("2020-03-01T00:00:00.000Z")) + // `*/15` expands to days [1, 16, 31] and exhibits the same wrap. + deepStrictEqual( + next(Cron.parseUnsafe("0 0 */15 * *", tz), new Date("2020-02-18T00:00:00.000Z")), + new Date("2020-03-01T00:00:00.000Z") + ) + // 30-day month: from the 20th the next day is the 31st (missing) -> July 1. + deepStrictEqual(next(cron, new Date("2024-06-20T00:00:00.000Z")), new Date("2024-07-01T00:00:00.000Z")) + }) + it("prev", () => { const utc = DateTime.zoneMakeNamedUnsafe("UTC") const before = new Date("2024-01-04T16:21:00Z")