calendar/src/utils/events.js
2025-08-27 11:15:27 -06:00

236 lines
9.6 KiB
JavaScript

import * as dateFns from 'date-fns'
import { fromLocalString, toLocalString, getMondayOfISOWeek, makeTZDate, DEFAULT_TZ } from './date'
import { addDays, isBefore, isAfter, differenceInCalendarDays } from 'date-fns'
function countPatternDaysInInterval(startDate, endDate, patternArr) {
const days = dateFns.eachDayOfInterval({
start: dateFns.startOfDay(startDate),
end: dateFns.startOfDay(endDate),
})
return days.reduce((c, d) => c + (patternArr[dateFns.getDay(d)] ? 1 : 0), 0)
}
export function getNWeekly(event, dateStr, timeZone = DEFAULT_TZ) {
const { recur } = event
if (!recur || recur.freq !== 'weeks') return null
const pattern = recur.weekdays || []
if (!pattern.some(Boolean)) return null
const target = fromLocalString(dateStr, timeZone)
const baseStart = fromLocalString(event.startDate, timeZone)
if (dateFns.isBefore(target, baseStart)) return null
const dow = dateFns.getDay(target)
if (!pattern[dow]) return null
const interval = recur.interval || 1
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
const currentBlockStart = getMondayOfISOWeek(target, timeZone)
const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart)
if (weekDiff < 0 || weekDiff % interval !== 0) return null
const baseDow = dateFns.getDay(baseStart)
const baseCountsAsPattern = !!pattern[baseDow]
if (weekDiff === 0) {
let n = countPatternDaysInInterval(baseStart, target, pattern) - 1
if (!baseCountsAsPattern) n += 1
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n < 0 || n >= maxCount ? null : n
}
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern)
const alignedWeeksBetween = weekDiff / interval - 1
const fullPatternWeekCount = pattern.filter(Boolean).length
const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
if (!baseCountsAsPattern) n += 1
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n >= maxCount ? null : n
}
function getNMonthly(event, dateStr, timeZone = DEFAULT_TZ) {
const { recur } = event
if (!recur || recur.freq !== 'months') return null
const baseStart = fromLocalString(event.startDate, timeZone)
const d = fromLocalString(dateStr, timeZone)
const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
if (diffMonths < 0) return null
const interval = recur.interval || 1
if (diffMonths % interval !== 0) return null
const baseDay = dateFns.getDate(baseStart)
const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
if (dateFns.getDate(d) !== effectiveDay) return null
const n = diffMonths / interval
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n >= maxCount ? null : n
}
function getN(event, dateStr, timeZone = DEFAULT_TZ) {
const { recur } = event
if (!recur) return null
const targetDate = fromLocalString(dateStr, timeZone)
const eventStartDate = fromLocalString(event.startDate, timeZone)
if (dateFns.isBefore(targetDate, eventStartDate)) return null
if (recur.freq === 'weeks') return getNWeekly(event, dateStr, timeZone)
if (recur.freq === 'months') return getNMonthly(event, dateStr, timeZone)
return null
}
// Reverse lookup: occurrence index -> start date string
function getDateWeekly(event, n, timeZone = DEFAULT_TZ) {
const { recur } = event
if (!recur || recur.freq !== 'weeks') return null
if (n < 0 || !Number.isInteger(n)) return null
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
if (n >= maxCount) return null
const pattern = recur.weekdays || []
if (!pattern.some(Boolean)) return null
const interval = recur.interval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
if (n === 0) return toLocalString(baseStart, timeZone)
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
const baseDow = dateFns.getDay(baseStart)
const baseCountsAsPattern = !!pattern[baseDow]
let occ = n
if (!baseCountsAsPattern) occ -= 1
if (occ < 0) return null
// Build Monday-based offsets (0=Mon..6=Sun) for all active pattern days
const offsets = pattern
.reduce((acc, active, i) => (active ? [...acc, (i + 6) % 7] : acc), [])
.sort((a, b) => a - b)
const dates = []
const baseOffset = (baseDow + 6) % 7
for (const off of offsets) {
if (off < baseOffset) continue
const d = dateFns.addDays(baseWeekMonday, off)
if (d < baseStart) continue
dates.push(d)
}
const F = dates.length
if (occ < F) return toLocalString(dates[occ], timeZone)
const remaining = occ - F
const P = offsets.length
if (P === 0) return null
const k = Math.floor(remaining / P) + 1
const indexInWeek = remaining % P
const date = dateFns.addDays(baseWeekMonday, k * interval * 7 + offsets[indexInWeek])
return toLocalString(date, timeZone)
}
function getDateMonthly(event, n, timeZone = DEFAULT_TZ) {
const { recur } = event
if (!recur || recur.freq !== 'months') return null
if (n < 0 || !Number.isInteger(n)) return null
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
if (n >= maxCount) return null
const interval = recur.interval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
const targetMonthOffset = n * interval
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
const baseDay = dateFns.getDate(baseStart)
const daysInTargetMonth = dateFns.getDaysInMonth(monthDate)
const day = Math.min(baseDay, daysInTargetMonth)
const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone)
return toLocalString(actual, timeZone)
}
export function getDate(event, n, timeZone = DEFAULT_TZ) {
const { recur } = event
if (!recur) return null
if (recur.freq === 'weeks') return getDateWeekly(event, n, timeZone)
if (recur.freq === 'months') return getDateMonthly(event, n, timeZone)
return null
}
/**
* Return nearest occurrence (past or future) relative to a reference date (date-string yyyy-MM-dd).
* Falls back to first/last when reference lies before first or after last (bounded by cap).
* Returns { n, dateStr } or null if no recurrence / invalid.
*/
export function getNearestOccurrence(event, referenceDateStr, timeZone = DEFAULT_TZ, cap = 5000) {
if (!event) return null
if (!event.recur) return { n: 0, dateStr: event.startDate }
const { recur } = event
if (!recur || !['weeks', 'months'].includes(recur.freq)) return { n: 0, dateStr: event.startDate }
const refDate = fromLocalString(referenceDateStr, timeZone)
const baseDate = fromLocalString(event.startDate, timeZone)
if (refDate <= baseDate) return { n: 0, dateStr: event.startDate }
const maxCount = recur.count === 'unlimited' ? cap : Math.min(parseInt(recur.count, 10) || 0, cap)
if (maxCount <= 0) return null
let low = 0
let high = maxCount - 1
let candidateGE = null
while (low <= high) {
const mid = (low + high) >> 1
const midStr = getDate(event, mid, timeZone)
if (!midStr) {
// invalid mid (should rarely happen) shrink high
high = mid - 1
continue
}
const midDate = fromLocalString(midStr, timeZone)
if (midDate >= refDate) {
candidateGE = { n: mid, dateStr: midStr, date: midDate }
high = mid - 1
} else {
low = mid + 1
}
}
let candidateLT = null
if (candidateGE) {
const prevN = candidateGE.n - 1
if (prevN >= 0) {
const prevStr = getDate(event, prevN, timeZone)
if (prevStr) {
candidateLT = { n: prevN, dateStr: prevStr, date: fromLocalString(prevStr, timeZone) }
}
}
} else {
// All occurrences earlier than ref
const lastN = maxCount - 1
const lastStr = getDate(event, lastN, timeZone)
if (lastStr)
candidateLT = { n: lastN, dateStr: lastStr, date: fromLocalString(lastStr, timeZone) }
}
if (candidateGE && candidateLT) {
const diffGE = candidateGE.date - refDate
const diffLT = refDate - candidateLT.date
return diffLT <= diffGE
? { n: candidateLT.n, dateStr: candidateLT.dateStr }
: { n: candidateGE.n, dateStr: candidateGE.dateStr }
}
if (candidateGE) return { n: candidateGE.n, dateStr: candidateGE.dateStr }
if (candidateLT) return { n: candidateLT.n, dateStr: candidateLT.dateStr }
return null
}
export function buildDayEvents(events, dateStr, timeZone = DEFAULT_TZ) {
const date = fromLocalString(dateStr, timeZone)
const out = []
for (const ev of events) {
const spanDays = ev.days || 1
if (!ev.recur) {
const baseStart = fromLocalString(ev.startDate, timeZone)
const baseEnd = addDays(baseStart, spanDays - 1)
if (!isBefore(date, baseStart) && !isAfter(date, baseEnd)) {
const diffDays = differenceInCalendarDays(date, baseStart)
out.push({ ...ev, n: 0, nDay: diffDays })
}
continue
}
// Recurring: gather all events whose start for any recurrence lies within spanDays window
const maxBack = Math.min(spanDays - 1, spanScanCap(spanDays))
for (let back = 0; back <= maxBack; back++) {
const candidateStart = addDays(date, -back)
const candidateStartStr = toLocalString(candidateStart, timeZone)
const n = getN(ev, candidateStartStr, timeZone)
if (n === null) continue
if (back >= spanDays) continue
out.push({ ...ev, n, nDay: back })
}
}
return out
}
function spanScanCap(spanDays) {
if (spanDays <= 31) return spanDays - 1
return Math.min(spanDays - 1, 90)
}