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