Skip to content

Commit e000f80

Browse files
authored
Fix Cron.prev month day rollover (#6472)
1 parent 2b58a3d commit e000f80

3 files changed

Lines changed: 56 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": patch
3+
---
4+
5+
Fix `Cron.prev` day-of-month rollover across shorter months and non-leap years.

packages/effect/src/Cron.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -878,10 +878,20 @@ const stepCron = (cron: Cron, now: DateTime.DateTime.Input | undefined, directio
878878
const nextDay = table.day[currentDay]
879879
if (nextDay === undefined) {
880880
if (reverse) {
881-
const prevMonthDays = daysInMonth(
882-
new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), 0))
883-
)
884-
b = -(currentDay + (prevMonthDays - boundary.day))
881+
const previous = new Date(current)
882+
// Day zero is the previous month's last day. These two probes cover every
883+
// valid day-of-month.
884+
previous.setUTCDate(0)
885+
let day = table.day[previous.getUTCDate()]
886+
if (day === undefined) {
887+
previous.setUTCDate(0)
888+
day = table.day[previous.getUTCDate()]
889+
}
890+
if (day === undefined) {
891+
throw new Error("Unable to find cron date")
892+
}
893+
previous.setUTCDate(day)
894+
b = (previous.getTime() - current.getTime()) / 86_400_000
885895
} else {
886896
b = daysInMonth(current) - currentDay + boundary.day
887897
}
@@ -908,10 +918,10 @@ const stepCron = (cron: Cron, now: DateTime.DateTime.Input | undefined, directio
908918
const currentMonth = current.getUTCMonth() + 1
909919
const nextMonth = table.month[currentMonth]
910920
const clampBoundaryDay = (targetMonthIndex: number): number => {
921+
const maxDayInMonth = daysInMonth(new Date(Date.UTC(current.getUTCFullYear(), targetMonthIndex + 1, 0)))
911922
if (cron.days.size !== 0 && cron.weekdays.size === 0) {
912-
return boundary.day
923+
return reverse ? table.day[maxDayInMonth] ?? maxDayInMonth : boundary.day
913924
}
914-
const maxDayInMonth = daysInMonth(new Date(Date.UTC(current.getUTCFullYear(), targetMonthIndex + 1, 0)))
915925
return reverse ? maxDayInMonth : 1
916926
}
917927
if (nextMonth === undefined) {
@@ -933,7 +943,7 @@ const stepCron = (cron: Cron, now: DateTime.DateTime.Input | undefined, directio
933943
return
934944
}
935945

936-
throw new Error("Unable to find " + direction + " cron date")
946+
throw new Error("Unable to find cron date")
937947
})
938948

939949
return dateTime.toDateUtc(result)

packages/effect/test/Cron.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,40 @@ describe("Cron", () => {
564564
deepStrictEqual(prev(cron, from), new Date("2024-01-31T00:00:00.000Z"))
565565
})
566566

567+
it("prev skips an invalid day in the immediately preceding month", () => {
568+
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
569+
const cron = Cron.parseUnsafe("0 0 31 * *", tz)
570+
const from = new Date("2024-03-15T00:00:00.000Z")
571+
deepStrictEqual(prev(cron, from), new Date("2024-01-31T00:00:00.000Z"))
572+
})
573+
574+
it("prev finds the previous leap day", () => {
575+
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
576+
const cron = Cron.parseUnsafe("0 0 29 2 *", tz)
577+
const from = new Date("2024-01-01T00:00:00.000Z")
578+
deepStrictEqual(prev(cron, from), new Date("2020-02-29T00:00:00.000Z"))
579+
})
580+
581+
it("prev day-of-month rollovers preserve match and ordering invariants", () => {
582+
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
583+
const cases = [
584+
[Cron.parseUnsafe("0 0 31 * *", tz), new Date("2024-03-15T00:00:00.000Z")],
585+
[Cron.parseUnsafe("0 0 29 2 *", tz), new Date("2024-01-01T00:00:00.000Z")]
586+
] as const
587+
588+
for (const [cron, from] of cases) {
589+
const result = prev(cron, from)
590+
assertTrue(Cron.match(cron, result))
591+
assertTrue(result < from)
592+
}
593+
})
594+
595+
it("prev terminates for an impossible day and month combination", () => {
596+
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
597+
const cron = Cron.parseUnsafe("0 0 30 2 *", tz)
598+
throws(() => prev(cron, new Date("2024-03-01T00:00:00.000Z")))
599+
})
600+
567601
it("prev clamps to the last valid day when rolling back a month with only month constraints", () => {
568602
const tz = DateTime.zoneMakeNamedUnsafe("UTC")
569603
const cron = Cron.parseUnsafe("0 0 0 * FEB *", tz)

0 commit comments

Comments
 (0)