Skip to content

Commit c881c6d

Browse files
kitlangtontmcwfubhy
authored andcommitted
Add Cron.prev reverse iteration support (#5786)
Co-authored-by: Tom MacWright <tom@macwright.com> Co-authored-by: Sebastian Lorenz <fubhy@fubhy.com>
1 parent 4ab4e0a commit c881c6d

File tree

4 files changed

+328
-46
lines changed

4 files changed

+328
-46
lines changed

.changeset/add-cron-prev.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": minor
3+
---
4+
5+
Add `Cron.prev` and reverse iteration support, aligning next/prev lookup tables, fixing DST handling symmetry, and expanding cron backward/forward test coverage.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@
9191
"workerd"
9292
],
9393
"onlyBuiltDependencies": [
94-
"better-sqlite3"
94+
"@parcel/watcher",
95+
"better-sqlite3",
96+
"sharp",
97+
"unrs-resolver"
9598
]
9699
}
97100
}

packages/effect/src/Cron.ts

Lines changed: 175 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable {
5353
readonly weekday: number
5454
}
5555
/** @internal */
56+
readonly last: {
57+
readonly second: number
58+
readonly minute: number
59+
readonly hour: number
60+
readonly day: number
61+
readonly month: number
62+
readonly weekday: number
63+
}
64+
/** @internal */
5665
readonly next: {
5766
readonly second: ReadonlyArray<number | undefined>
5867
readonly minute: ReadonlyArray<number | undefined>
@@ -61,6 +70,15 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable {
6170
readonly month: ReadonlyArray<number | undefined>
6271
readonly weekday: ReadonlyArray<number | undefined>
6372
}
73+
/** @internal */
74+
readonly prev: {
75+
readonly second: ReadonlyArray<number | undefined>
76+
readonly minute: ReadonlyArray<number | undefined>
77+
readonly hour: ReadonlyArray<number | undefined>
78+
readonly day: ReadonlyArray<number | undefined>
79+
readonly month: ReadonlyArray<number | undefined>
80+
readonly weekday: ReadonlyArray<number | undefined>
81+
}
6482
}
6583

6684
const CronProto = {
@@ -151,31 +169,64 @@ export const make = (values: {
151169
weekday: weekdays[0] ?? 0
152170
}
153171

172+
o.last = {
173+
second: seconds[seconds.length - 1] ?? 59,
174+
minute: minutes[minutes.length - 1] ?? 59,
175+
hour: hours[hours.length - 1] ?? 23,
176+
day: days[days.length - 1] ?? 31,
177+
month: (months[months.length - 1] ?? 12) - 1,
178+
weekday: weekdays[weekdays.length - 1] ?? 6
179+
}
180+
154181
o.next = {
155-
second: nextLookupTable(seconds, 60),
156-
minute: nextLookupTable(minutes, 60),
157-
hour: nextLookupTable(hours, 24),
158-
day: nextLookupTable(days, 32),
159-
month: nextLookupTable(months, 13),
160-
weekday: nextLookupTable(weekdays, 7)
182+
second: lookupTable(seconds, 60, "next"),
183+
minute: lookupTable(minutes, 60, "next"),
184+
hour: lookupTable(hours, 24, "next"),
185+
day: lookupTable(days, 32, "next"),
186+
month: lookupTable(months, 13, "next"),
187+
weekday: lookupTable(weekdays, 7, "next")
188+
}
189+
190+
o.prev = {
191+
second: lookupTable(seconds, 60, "prev"),
192+
minute: lookupTable(minutes, 60, "prev"),
193+
hour: lookupTable(hours, 24, "prev"),
194+
day: lookupTable(days, 32, "prev"),
195+
month: lookupTable(months, 13, "prev"),
196+
weekday: lookupTable(weekdays, 7, "prev")
161197
}
162198

163199
return o
164200
}
165201

166-
const nextLookupTable = (values: ReadonlyArray<number>, size: number): Array<number | undefined> => {
202+
const lookupTable = (
203+
values: ReadonlyArray<number>,
204+
size: number,
205+
dir: "next" | "prev"
206+
): Array<number | undefined> => {
167207
const result = new Array(size).fill(undefined)
168208
if (values.length === 0) {
169209
return result
170210
}
171211

172212
let current: number | undefined = undefined
173-
let index = values.length - 1
174-
for (let i = size - 1; i >= 0; i--) {
175-
while (index >= 0 && values[index] >= i) {
176-
current = values[index--]
213+
214+
if (dir === "next") {
215+
let index = values.length - 1
216+
for (let i = size - 1; i >= 0; i--) {
217+
while (index >= 0 && values[index] >= i) {
218+
current = values[index--]
219+
}
220+
result[i] = current
221+
}
222+
} else {
223+
let index = 0
224+
for (let i = 0; i < size; i++) {
225+
while (index < values.length && values[index] <= i) {
226+
current = values[index++]
227+
}
228+
result[i] = current
177229
}
178-
result[i] = current
179230
}
180231

181232
return result
@@ -376,7 +427,7 @@ const daysInMonth = (date: Date): number =>
376427
/**
377428
* Returns the next run `Date` for the given `Cron` instance.
378429
*
379-
* Uses the current time as a starting point if no value is provided for `now`.
430+
* Uses the current time as a starting point if no value is provided for `startFrom`.
380431
*
381432
* @example
382433
* ```ts
@@ -394,38 +445,76 @@ const daysInMonth = (date: Date): number =>
394445
* @since 2.0.0
395446
*/
396447
export const next = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => {
448+
return stepCron(cron, startFrom, "next")
449+
}
450+
451+
/**
452+
* Returns the previous run `Date` for the given `Cron` instance.
453+
*
454+
* Uses the current time as a starting point if no value is provided for `startFrom`.
455+
*
456+
* @example
457+
* ```ts
458+
* import * as assert from "node:assert"
459+
* import { Cron, Either } from "effect"
460+
*
461+
* const before = new Date("2021-01-15 00:00:00")
462+
* const cron = Either.getOrThrow(Cron.parse("0 4 8-14 * *"))
463+
* assert.deepStrictEqual(Cron.prev(cron, before), new Date("2021-01-14 04:00:00"))
464+
* ```
465+
*
466+
* @throws `IllegalArgumentException` if the given `DateTime.Input` is invalid.
467+
* @throws `Error` if the previous run date cannot be found within 10,000 iterations.
468+
*
469+
* @since 3.20.0
470+
*/
471+
export const prev = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => {
472+
return stepCron(cron, startFrom, "prev")
473+
}
474+
475+
/** @internal */
476+
const stepCron = (cron: Cron, startFrom: DateTime.DateTime.Input | undefined, direction: "next" | "prev"): Date => {
397477
const tz = Option.getOrUndefined(cron.tz)
398478
const zoned = dateTime.unsafeMakeZoned(startFrom ?? new Date(), {
399479
timeZone: tz
400480
})
401481

482+
const prev = direction === "prev"
483+
const tick = prev ? -1 : 1
484+
const table = cron[direction]
485+
const boundary = prev ? cron.last : cron.first
486+
487+
const needsStep = prev
488+
? (next: number, current: number) => next < current
489+
: (next: number, current: number) => next > current
490+
402491
const utc = tz !== undefined && dateTime.isTimeZoneNamed(tz) && tz.id === "UTC"
403492
const adjustDst = utc ? constVoid : (current: Date) => {
404493
const adjusted = dateTime.unsafeMakeZoned(current, {
405494
timeZone: zoned.zone,
406-
adjustForTimeZone: true
495+
adjustForTimeZone: true,
496+
disambiguation: prev ? "later" : undefined
407497
}).pipe(dateTime.toDate)
408498

409-
// TODO: This implementation currently only skips forward when transitioning into daylight savings time.
410499
const drift = current.getTime() - adjusted.getTime()
411-
if (drift > 0) {
412-
current.setTime(current.getTime() + drift)
500+
if (prev ? drift !== 0 : drift > 0) {
501+
current.setTime(adjusted.getTime())
413502
}
414503
}
415504

416505
const result = dateTime.mutate(zoned, (current) => {
417-
current.setUTCSeconds(current.getUTCSeconds() + 1, 0)
506+
current.setUTCSeconds(current.getUTCSeconds() + tick, 0)
418507

419508
for (let i = 0; i < 10_000; i++) {
420509
if (cron.seconds.size !== 0) {
421510
const currentSecond = current.getUTCSeconds()
422-
const nextSecond = cron.next.second[currentSecond]
511+
const nextSecond = table.second[currentSecond]
423512
if (nextSecond === undefined) {
424-
current.setUTCMinutes(current.getUTCMinutes() + 1, cron.first.second)
513+
current.setUTCMinutes(current.getUTCMinutes() + tick, boundary.second)
425514
adjustDst(current)
426515
continue
427516
}
428-
if (nextSecond > currentSecond) {
517+
if (needsStep(nextSecond, currentSecond)) {
429518
current.setUTCSeconds(nextSecond)
430519
adjustDst(current)
431520
continue
@@ -434,73 +523,102 @@ export const next = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => {
434523

435524
if (cron.minutes.size !== 0) {
436525
const currentMinute = current.getUTCMinutes()
437-
const nextMinute = cron.next.minute[currentMinute]
526+
const nextMinute = table.minute[currentMinute]
438527
if (nextMinute === undefined) {
439-
current.setUTCHours(current.getUTCHours() + 1, cron.first.minute, cron.first.second)
528+
current.setUTCHours(current.getUTCHours() + tick, boundary.minute, boundary.second)
440529
adjustDst(current)
441530
continue
442531
}
443-
if (nextMinute > currentMinute) {
444-
current.setUTCMinutes(nextMinute, cron.first.second)
532+
if (needsStep(nextMinute, currentMinute)) {
533+
current.setUTCMinutes(nextMinute, boundary.second)
445534
adjustDst(current)
446535
continue
447536
}
448537
}
449538

450539
if (cron.hours.size !== 0) {
451540
const currentHour = current.getUTCHours()
452-
const nextHour = cron.next.hour[currentHour]
541+
const nextHour = table.hour[currentHour]
453542
if (nextHour === undefined) {
454-
current.setUTCDate(current.getUTCDate() + 1)
455-
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
543+
current.setUTCDate(current.getUTCDate() + tick)
544+
current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
456545
adjustDst(current)
457546
continue
458547
}
459-
if (nextHour > currentHour) {
460-
current.setUTCHours(nextHour, cron.first.minute, cron.first.second)
548+
if (needsStep(nextHour, currentHour)) {
549+
current.setUTCHours(nextHour, boundary.minute, boundary.second)
461550
adjustDst(current)
462551
continue
463552
}
464553
}
465554

466555
if (cron.weekdays.size !== 0 || cron.days.size !== 0) {
467-
let a: number = Infinity
468-
let b: number = Infinity
556+
let a: number = prev ? -Infinity : Infinity
557+
let b: number = prev ? -Infinity : Infinity
469558

470559
if (cron.weekdays.size !== 0) {
471560
const currentWeekday = current.getUTCDay()
472-
const nextWeekday = cron.next.weekday[currentWeekday]
473-
a = nextWeekday === undefined ? 7 - currentWeekday + cron.first.weekday : nextWeekday - currentWeekday
561+
const nextWeekday = table.weekday[currentWeekday]
562+
if (nextWeekday === undefined) {
563+
a = prev
564+
? currentWeekday - 7 + boundary.weekday
565+
: 7 - currentWeekday + boundary.weekday
566+
} else {
567+
a = nextWeekday - currentWeekday
568+
}
474569
}
475570

571+
// Only check day-of-month if weekday constraint not already satisfied (they're OR'd)
476572
if (cron.days.size !== 0 && a !== 0) {
477573
const currentDay = current.getUTCDate()
478-
const nextDay = cron.next.day[currentDay]
479-
b = nextDay === undefined ? daysInMonth(current) - currentDay + cron.first.day : nextDay - currentDay
574+
const nextDay = table.day[currentDay]
575+
if (nextDay === undefined) {
576+
if (prev) {
577+
// When wrapping to previous month, calculate days back:
578+
// Current day offset + gap from end of prev month to target day
579+
// Example: June 3 → May 20 with boundary.day=20: -(3 + (31 - 20)) = -14
580+
const prevMonthDays = daysInMonth(
581+
new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), 0))
582+
)
583+
b = -(currentDay + (prevMonthDays - boundary.day))
584+
} else {
585+
b = daysInMonth(current) - currentDay + boundary.day
586+
}
587+
} else {
588+
b = nextDay - currentDay
589+
}
480590
}
481591

482-
const addDays = Math.min(a, b)
592+
const addDays = prev ? Math.max(a, b) : Math.min(a, b)
483593
if (addDays !== 0) {
484594
current.setUTCDate(current.getUTCDate() + addDays)
485-
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
595+
current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
486596
adjustDst(current)
487597
continue
488598
}
489599
}
490600

491601
if (cron.months.size !== 0) {
492602
const currentMonth = current.getUTCMonth() + 1
493-
const nextMonth = cron.next.month[currentMonth]
603+
const nextMonth = table.month[currentMonth]
604+
const clampBoundaryDay = (targetMonthIndex: number): number => {
605+
if (cron.days.size !== 0) {
606+
return boundary.day
607+
}
608+
const maxDayInMonth = daysInMonth(new Date(Date.UTC(current.getUTCFullYear(), targetMonthIndex, 1)))
609+
return Math.min(boundary.day, maxDayInMonth)
610+
}
494611
if (nextMonth === undefined) {
495-
current.setUTCFullYear(current.getUTCFullYear() + 1)
496-
current.setUTCMonth(cron.first.month, cron.first.day)
497-
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
612+
current.setUTCFullYear(current.getUTCFullYear() + tick)
613+
current.setUTCMonth(boundary.month, clampBoundaryDay(boundary.month))
614+
current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
498615
adjustDst(current)
499616
continue
500617
}
501-
if (nextMonth > currentMonth) {
502-
current.setUTCMonth(nextMonth - 1, cron.first.day)
503-
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
618+
if (needsStep(nextMonth, currentMonth)) {
619+
const targetMonthIndex = nextMonth - 1
620+
current.setUTCMonth(targetMonthIndex, clampBoundaryDay(targetMonthIndex))
621+
current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
504622
adjustDst(current)
505623
continue
506624
}
@@ -526,6 +644,18 @@ export const sequence = function*(cron: Cron, startFrom?: DateTime.DateTime.Inpu
526644
}
527645
}
528646

647+
/**
648+
* Returns an `IterableIterator` which yields the sequence of `Date`s that match the `Cron` instance,
649+
* in reverse direction.
650+
*
651+
* @since 3.20.0
652+
*/
653+
export const sequenceReverse = function*(cron: Cron, startFrom?: DateTime.DateTime.Input): IterableIterator<Date> {
654+
while (true) {
655+
yield startFrom = prev(cron, startFrom)
656+
}
657+
}
658+
529659
/**
530660
* @category instances
531661
* @since 2.0.0

0 commit comments

Comments
 (0)