236 lines
9.6 KiB
JavaScript
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)
|
|
}
|