Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-cron-prev.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": minor
---

Add `Cron.prev` and reverse iteration support, aligning next/prev lookup tables, fixing DST handling symmetry, and expanding cron backward/forward test coverage.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@
"workerd"
],
"onlyBuiltDependencies": [
"better-sqlite3"
"@parcel/watcher",
"better-sqlite3",
"sharp",
"unrs-resolver"
]
}
}
231 changes: 186 additions & 45 deletions packages/effect/src/Cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable {
readonly weekday: number
}
/** @internal */
readonly last: {
readonly second: number
readonly minute: number
readonly hour: number
readonly day: number
readonly month: number
readonly weekday: number
}
/** @internal */
readonly next: {
readonly second: ReadonlyArray<number | undefined>
readonly minute: ReadonlyArray<number | undefined>
Expand All @@ -61,6 +70,15 @@ export interface Cron extends Pipeable, Equal.Equal, Inspectable {
readonly month: ReadonlyArray<number | undefined>
readonly weekday: ReadonlyArray<number | undefined>
}
/** @internal */
readonly prev: {
readonly second: ReadonlyArray<number | undefined>
readonly minute: ReadonlyArray<number | undefined>
readonly hour: ReadonlyArray<number | undefined>
readonly day: ReadonlyArray<number | undefined>
readonly month: ReadonlyArray<number | undefined>
readonly weekday: ReadonlyArray<number | undefined>
}
}

const CronProto = {
Expand Down Expand Up @@ -151,31 +169,64 @@ export const make = (values: {
weekday: weekdays[0] ?? 0
}

o.last = {
second: seconds[seconds.length - 1] ?? 59,
minute: minutes[minutes.length - 1] ?? 59,
hour: hours[hours.length - 1] ?? 23,
day: days[days.length - 1] ?? 31,
month: (months[months.length - 1] ?? 12) - 1,
weekday: weekdays[weekdays.length - 1] ?? 6
}

o.next = {
second: nextLookupTable(seconds, 60),
minute: nextLookupTable(minutes, 60),
hour: nextLookupTable(hours, 24),
day: nextLookupTable(days, 32),
month: nextLookupTable(months, 13),
weekday: nextLookupTable(weekdays, 7)
second: lookupTable(seconds, 60, "next"),
minute: lookupTable(minutes, 60, "next"),
hour: lookupTable(hours, 24, "next"),
day: lookupTable(days, 32, "next"),
month: lookupTable(months, 13, "next"),
weekday: lookupTable(weekdays, 7, "next")
}

o.prev = {
second: lookupTable(seconds, 60, "prev"),
minute: lookupTable(minutes, 60, "prev"),
hour: lookupTable(hours, 24, "prev"),
day: lookupTable(days, 32, "prev"),
month: lookupTable(months, 13, "prev"),
weekday: lookupTable(weekdays, 7, "prev")
}

return o
}

const nextLookupTable = (values: ReadonlyArray<number>, size: number): Array<number | undefined> => {
const lookupTable = (
values: ReadonlyArray<number>,
size: number,
dir: "next" | "prev"
): Array<number | undefined> => {
const result = new Array(size).fill(undefined)
if (values.length === 0) {
return result
}

let current: number | undefined = undefined
let index = values.length - 1
for (let i = size - 1; i >= 0; i--) {
while (index >= 0 && values[index] >= i) {
current = values[index--]

if (dir === "next") {
let index = values.length - 1
for (let i = size - 1; i >= 0; i--) {
while (index >= 0 && values[index] >= i) {
current = values[index--]
}
result[i] = current
}
} else {
let index = 0
for (let i = 0; i < size; i++) {
while (index < values.length && values[index] <= i) {
current = values[index++]
}
result[i] = current
}
result[i] = current
}

return result
Expand Down Expand Up @@ -391,41 +442,71 @@ const daysInMonth = (date: Date): number =>
* @throws `IllegalArgumentException` if the given `DateTime.Input` is invalid.
* @throws `Error` if the next run date cannot be found within 10,000 iterations.
*
* @since 2.0.0
* @since 3.20.0
*/
export const next = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => {
return stepCron(cron, startFrom, "next")
}

/**
* Returns the previous run `Date` for the given `Cron` instance.
*
* Uses the current time as a starting point if no value is provided for `startFrom`.
*
* @throws `IllegalArgumentException` if the given `DateTime.Input` is invalid.
* @throws `Error` if the previous run date cannot be found within 10,000 iterations.
*
* @since 3.20.0
*/
export const prev = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => {
return stepCron(cron, startFrom, "prev")
}

/** @internal */
const stepCron = (cron: Cron, startFrom: DateTime.DateTime.Input | undefined, direction: "next" | "prev"): Date => {
const tz = Option.getOrUndefined(cron.tz)
const zoned = dateTime.unsafeMakeZoned(startFrom ?? new Date(), {
timeZone: tz
})
const tick = direction === "next" ? 1 : -1
const table = cron[direction]
const boundary = direction === "next" ? cron.first : cron.last

const utc = tz !== undefined && dateTime.isTimeZoneNamed(tz) && tz.id === "UTC"
const adjustDst = utc ? constVoid : (current: Date) => {
const adjusted = dateTime.unsafeMakeZoned(current, {
timeZone: zoned.zone,
adjustForTimeZone: true
adjustForTimeZone: true,
disambiguation: direction === "prev" ? "later" : undefined
}).pipe(dateTime.toDate)

// TODO: This implementation currently only skips forward when transitioning into daylight savings time.
const drift = current.getTime() - adjusted.getTime()
if (drift > 0) {
current.setTime(current.getTime() + drift)
if (direction === "prev") {
if (drift !== 0) {
current.setTime(adjusted.getTime())
}
} else if (drift > 0) {
current.setTime(adjusted.getTime())
}
}

const result = dateTime.mutate(zoned, (current) => {
current.setUTCSeconds(current.getUTCSeconds() + 1, 0)
current.setUTCSeconds(current.getUTCSeconds() + tick, 0)

for (let i = 0; i < 10_000; i++) {
if (cron.seconds.size !== 0) {
const currentSecond = current.getUTCSeconds()
const nextSecond = cron.next.second[currentSecond]
const nextSecond = table.second[currentSecond]
if (nextSecond === undefined) {
current.setUTCMinutes(current.getUTCMinutes() + 1, cron.first.second)
current.setUTCMinutes(current.getUTCMinutes() + tick, boundary.second)
adjustDst(current)
continue
}
if (nextSecond > currentSecond) {
if (
direction === "next" ?
nextSecond > currentSecond :
nextSecond < currentSecond
) {
current.setUTCSeconds(nextSecond)
adjustDst(current)
continue
Expand All @@ -434,73 +515,121 @@ export const next = (cron: Cron, startFrom?: DateTime.DateTime.Input): Date => {

if (cron.minutes.size !== 0) {
const currentMinute = current.getUTCMinutes()
const nextMinute = cron.next.minute[currentMinute]
const nextMinute = table.minute[currentMinute]
if (nextMinute === undefined) {
current.setUTCHours(current.getUTCHours() + 1, cron.first.minute, cron.first.second)
current.setUTCHours(
current.getUTCHours() + tick,
boundary.minute,
boundary.second
)
adjustDst(current)
continue
}
if (nextMinute > currentMinute) {
current.setUTCMinutes(nextMinute, cron.first.second)
if (
direction === "next" ?
nextMinute > currentMinute :
nextMinute < currentMinute
) {
current.setUTCMinutes(nextMinute, boundary.second)
adjustDst(current)
continue
}
}

if (cron.hours.size !== 0) {
const currentHour = current.getUTCHours()
const nextHour = cron.next.hour[currentHour]
const nextHour = table.hour[currentHour]
if (nextHour === undefined) {
current.setUTCDate(current.getUTCDate() + 1)
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
current.setUTCDate(current.getUTCDate() + tick)
current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
adjustDst(current)
continue
}
if (nextHour > currentHour) {
current.setUTCHours(nextHour, cron.first.minute, cron.first.second)
if (
direction === "next" ?
nextHour > currentHour :
nextHour < currentHour
) {
current.setUTCHours(nextHour, boundary.minute, boundary.second)
adjustDst(current)
continue
}
}

if (cron.weekdays.size !== 0 || cron.days.size !== 0) {
let a: number = Infinity
let b: number = Infinity
let a: number = direction === "next" ? Infinity : -Infinity
let b: number = direction === "next" ? Infinity : -Infinity
const wrapWeekday = direction === "next" ? cron.first.weekday : cron.last.weekday
const wrapDay = direction === "next" ? cron.first.day : cron.last.day

if (cron.weekdays.size !== 0) {
const currentWeekday = current.getUTCDay()
const nextWeekday = cron.next.weekday[currentWeekday]
a = nextWeekday === undefined ? 7 - currentWeekday + cron.first.weekday : nextWeekday - currentWeekday
const nextWeekday = table.weekday[currentWeekday]
a = nextWeekday === undefined ?
(direction === "next" ?
7 - currentWeekday + wrapWeekday :
currentWeekday - 7 + wrapWeekday) :
nextWeekday - currentWeekday
}

// Only check day-of-month if weekday constraint not already satisfied (they're OR'd)
if (cron.days.size !== 0 && a !== 0) {
const currentDay = current.getUTCDate()
const nextDay = cron.next.day[currentDay]
b = nextDay === undefined ? daysInMonth(current) - currentDay + cron.first.day : nextDay - currentDay
const nextDay = table.day[currentDay]
b = nextDay === undefined ?
(
direction === "next" ?
daysInMonth(current) - currentDay + wrapDay :
// When wrapping to previous month, calculate days back:
// Current day offset + gap from end of prev month to target day
// Example: June 3 → May 20 with wrapDay=20: -(3 + (31 - 20)) = -14
-(currentDay + (daysInMonth(
new Date(Date.UTC(
current.getUTCFullYear(),
current.getUTCMonth(),
0
))
) - wrapDay))
) :
nextDay - currentDay
}

const addDays = Math.min(a, b)
const addDays = direction === "next" ? Math.min(a, b) : Math.max(a, b)
if (addDays !== 0) {
current.setUTCDate(current.getUTCDate() + addDays)
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
adjustDst(current)
continue
}
}

if (cron.months.size !== 0) {
const currentMonth = current.getUTCMonth() + 1
const nextMonth = cron.next.month[currentMonth]
const nextMonth = table.month[currentMonth]
const clampBoundaryDay = (targetMonthIndex: number): number => {
if (cron.days.size !== 0) {
return boundary.day
}
const maxDayInMonth = daysInMonth(
new Date(Date.UTC(current.getUTCFullYear(), targetMonthIndex, 1))
)
return Math.min(boundary.day, maxDayInMonth)
}
if (nextMonth === undefined) {
current.setUTCFullYear(current.getUTCFullYear() + 1)
current.setUTCMonth(cron.first.month, cron.first.day)
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
current.setUTCFullYear(current.getUTCFullYear() + tick)
current.setUTCMonth(boundary.month, clampBoundaryDay(boundary.month))
current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
adjustDst(current)
continue
}
if (nextMonth > currentMonth) {
current.setUTCMonth(nextMonth - 1, cron.first.day)
current.setUTCHours(cron.first.hour, cron.first.minute, cron.first.second)
if (
direction === "next" ?
nextMonth > currentMonth :
nextMonth < currentMonth
) {
const targetMonthIndex = nextMonth - 1
current.setUTCMonth(targetMonthIndex, clampBoundaryDay(targetMonthIndex))
current.setUTCHours(boundary.hour, boundary.minute, boundary.second)
adjustDst(current)
continue
}
Expand All @@ -526,6 +655,18 @@ export const sequence = function*(cron: Cron, startFrom?: DateTime.DateTime.Inpu
}
}

/**
* Returns an `IterableIterator` which yields the sequence of `Date`s that match the `Cron` instance,
* in reverse direction.
*
* @since 3.20.0
*/
export const sequenceReverse = function*(cron: Cron, startFrom?: DateTime.DateTime.Input): IterableIterator<Date> {
while (true) {
yield startFrom = prev(cron, startFrom)
}
}

/**
* @category instances
* @since 2.0.0
Expand Down
Loading