export type ScheduleDay = | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday' export type ScheduleFrequency = | 'daily' | 'weekly' | 'biweekly' | 'monthlyByDate' | 'monthlyByWeekday' export type WeekOfMonth = 'first' | 'second' | 'third' | 'fourth' | 'last' export interface ScheduleEntry { frequency: ScheduleFrequency day?: ScheduleDay weekOfMonth?: WeekOfMonth | null biweeklyAnchor?: string | Date | null dayOfMonth?: number | null } // JS getDay(): 0 = Sunday ... 6 = Saturday const DAY_INDEX: Record = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, } const WEEK_OF_MONTH_N: Record = { first: 1, second: 2, third: 3, fourth: 4, last: -1, } const MS_PER_DAY = 24 * 60 * 60 * 1000 const startOfDay = (d: Date): Date => { const r = new Date(d) r.setHours(0, 0, 0, 0) return r } const addDays = (d: Date, n: number): Date => { const r = new Date(d) r.setDate(r.getDate() + n) return r } const firstOccurrenceOnOrAfter = (from: Date, targetDay: number): Date => { const start = startOfDay(from) const diff = (targetDay - start.getDay() + 7) % 7 return addDays(start, diff) } /** * For a given (year, month), return the date of the Nth occurrence of * `targetDay`. `n` is 1..4 for first..fourth, or -1 for "last". * Returns null if the month has no such occurrence (e.g. asking for the * 5th Monday in a month that only has 4). */ const nthWeekdayOfMonth = ( year: number, month: number, targetDay: number, n: number, ): Date | null => { if (n > 0) { const first = new Date(year, month, 1) const diff = (targetDay - first.getDay() + 7) % 7 const day = 1 + diff + (n - 1) * 7 const daysInMonth = new Date(year, month + 1, 0).getDate() if (day > daysInMonth) return null return new Date(year, month, day) } // last occurrence of targetDay in month const lastDay = new Date(year, month + 1, 0) const diff = (lastDay.getDay() - targetDay + 7) % 7 return new Date(year, month, lastDay.getDate() - diff) } /** * Return every calendar date (time-of-day at local midnight) on which * this schedule entry should fire, within [after, before]. * * Dates only — the caller combines each date with the schedule's * time-of-day via {@link combineDateAndTime}. Splitting date from time * makes DST transitions a non-issue: we always build the final Date * with a local-time constructor. */ export const generateOccurrenceDates = ( entry: ScheduleEntry, after: Date, before: Date, ): Date[] => { if (before.getTime() < after.getTime()) return [] const dates: Date[] = [] const afterStart = startOfDay(after) const beforeEnd = startOfDay(before) // Frequencies that ride a specific weekday need a valid entry.day. const needsWeekday = entry.frequency === 'weekly' || entry.frequency === 'biweekly' || entry.frequency === 'monthlyByWeekday' const targetDay = entry.day !== undefined ? DAY_INDEX[entry.day] : undefined if (needsWeekday && targetDay === undefined) return [] switch (entry.frequency) { case 'daily': { let cursor = afterStart while (cursor.getTime() <= beforeEnd.getTime()) { dates.push(cursor) cursor = addDays(cursor, 1) } return dates } case 'weekly': { let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay!) while (cursor.getTime() <= beforeEnd.getTime()) { dates.push(cursor) cursor = addDays(cursor, 7) } return dates } case 'biweekly': { if (!entry.biweeklyAnchor) return [] const anchor = startOfDay(new Date(entry.biweeklyAnchor)) if (Number.isNaN(anchor.getTime())) return [] let cursor = firstOccurrenceOnOrAfter(afterStart, targetDay!) // Align cursor with anchor's 2-week parity. Compute the whole-day // delta using midday to avoid DST rounding pushing it off by one. const daysFromAnchor = Math.round( (cursor.getTime() + MS_PER_DAY / 2 - (anchor.getTime() + MS_PER_DAY / 2)) / MS_PER_DAY, ) const mod = ((daysFromAnchor % 14) + 14) % 14 if (mod !== 0) cursor = addDays(cursor, 14 - mod) while (cursor.getTime() <= beforeEnd.getTime()) { if (cursor.getTime() >= afterStart.getTime()) dates.push(cursor) cursor = addDays(cursor, 14) } return dates } case 'monthlyByDate': { if (typeof entry.dayOfMonth !== 'number') return [] const dom = entry.dayOfMonth if (dom < 1 || dom > 31) return [] let year = afterStart.getFullYear() let month = afterStart.getMonth() const endYear = beforeEnd.getFullYear() const endMonth = beforeEnd.getMonth() // Iterate month-by-month. Months without the requested day (e.g. the // 31st in April) are skipped — no clamp-down to the last day. while (year < endYear || (year === endYear && month <= endMonth)) { const daysInMonth = new Date(year, month + 1, 0).getDate() if (dom <= daysInMonth) { const occ = new Date(year, month, dom) if ( occ.getTime() >= afterStart.getTime() && occ.getTime() <= beforeEnd.getTime() ) { dates.push(occ) } } month += 1 if (month > 11) { month = 0 year += 1 } } return dates } case 'monthlyByWeekday': { if (!entry.weekOfMonth) return [] const n = WEEK_OF_MONTH_N[entry.weekOfMonth] let year = afterStart.getFullYear() let month = afterStart.getMonth() const endYear = beforeEnd.getFullYear() const endMonth = beforeEnd.getMonth() while (year < endYear || (year === endYear && month <= endMonth)) { const occ = nthWeekdayOfMonth(year, month, targetDay!, n) if ( occ && occ.getTime() >= afterStart.getTime() && occ.getTime() <= beforeEnd.getTime() ) { dates.push(occ) } month += 1 if (month > 11) { month = 0 year += 1 } } return dates } default: return [] } } /** * Combine a calendar date (year/month/day from `date`) with a wall-clock * time taken from `timeSource` (a Date whose local hours/minutes we read). * Always produced in the server's local timezone, which is what Payload's * date fields display. */ export const combineDateAndTime = (date: Date, timeSource: Date): Date => { const time = new Date(timeSource) return new Date( date.getFullYear(), date.getMonth(), date.getDate(), time.getHours(), time.getMinutes(), 0, 0, ) }