church-website/src/jobs/lib/scheduleOccurrences.ts
2026-04-23 15:05:04 +02:00

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,
)
}