@@ -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
6684const 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 */
396447export 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