242 lines
6.6 KiB
TypeScript
242 lines
6.6 KiB
TypeScript
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<ScheduleDay, number> = {
|
|
sunday: 0,
|
|
monday: 1,
|
|
tuesday: 2,
|
|
wednesday: 3,
|
|
thursday: 4,
|
|
friday: 5,
|
|
saturday: 6,
|
|
}
|
|
|
|
const WEEK_OF_MONTH_N: Record<WeekOfMonth, number> = {
|
|
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,
|
|
)
|
|
}
|