From 333590e7e2a28deb9d4fbd3e388714a52ffb2644 Mon Sep 17 00:00:00 2001 From: chatman-media Date: Mon, 22 Jun 2026 19:10:11 +0700 Subject: [PATCH] fix(Cron): do not skip earlier days when the upcoming day is missing from the month --- .changeset/fix-cron-next-missing-day-overflow.md | 5 +++++ packages/effect/src/Cron.ts | 6 ++++++ packages/effect/test/Cron.test.ts | 15 +++++++++++++++ 3 files changed, 26 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 00000000000..601e09f87e3 --- /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. For an expression like `0 0 1,16,31 * *`, advancing from a date past the 16th selected day 31; in a month without 31 days this overflowed into the following month and landed on a later matching day (e.g. the 16th), silently skipping the 1st. `Cron.next` now wraps to the first matching day of the next month in that case, matching the behaviour of `Cron.prev` and other cron implementations. diff --git a/packages/effect/src/Cron.ts b/packages/effect/src/Cron.ts index 43b16e9d28e..4064e8bfe4b 100644 --- a/packages/effect/src/Cron.ts +++ b/packages/effect/src/Cron.ts @@ -584,6 +584,12 @@ const stepCron = (cron: Cron, startFrom: DateTime.DateTime.Input | undefined, di } else { b = daysInMonth(current) - currentDay + boundary.day } + } else if (!prev && nextDay > daysInMonth(current)) { + // The next matching day does not exist in the current month (e.g. day 31 + // in a 30-day month). Setting it directly would overflow into the following + // month and skip its earlier matching days, so wrap to the first matching + // day of the next month instead. + 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 8448aa6cb2b..2dc16aaa12a 100644 --- a/packages/effect/test/Cron.test.ts +++ b/packages/effect/test/Cron.test.ts @@ -245,6 +245,21 @@ describe("Cron", () => { deepStrictEqual(prev(cron, from), new Date("2024-01-31T00:00:00.000Z")) }) + it("next does not skip earlier days when the upcoming day is missing from the month", () => { + const tz = DateTime.zoneUnsafeMakeNamed("UTC") + const cron = Cron.unsafeParse("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.unsafeParse("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 clamps to the last valid day when rolling back a month with only month constraints", () => { const tz = DateTime.zoneUnsafeMakeNamed("UTC") const cron = Cron.unsafeParse("0 0 0 * FEB *", tz)