Cleanup date and recurrence calculations.

This commit is contained in:
Leo Vasanko
2025-08-24 08:52:28 -06:00
parent e78ced2383
commit 8e926c0a21
2 changed files with 152 additions and 317 deletions

View File

@@ -139,6 +139,68 @@ function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
return null
}
// Reverse lookup: given a recurrence index (0-based) return the occurrence start date string.
// Returns null if the index is out of range or the event is not repeating.
function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat !== 'weeks') return null
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null
const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return null
const interval = event.repeatInterval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone)
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
const baseDow = dateFns.getDay(baseStart)
// Sorted list of active weekday indices
const patternDays = []
for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d)
// First (possibly partial) week: only pattern days >= baseDow and >= baseStart date
const firstWeekDates = []
for (const d of patternDays) {
if (d < baseDow) continue
const date = dateFns.addDays(baseWeekMonday, d)
if (date < baseStart) continue
firstWeekDates.push(date)
}
const F = firstWeekDates.length
if (occurrenceIndex < F) {
return toLocalString(firstWeekDates[occurrenceIndex], timeZone)
}
const remaining = occurrenceIndex - F
const P = patternDays.length
if (P === 0) return null
// Determine aligned week group (k >= 1) in which the remaining-th occurrence lies
const k = Math.floor(remaining / P) + 1 // 1-based aligned week count after base week
const indexInWeek = remaining % P
const dow = patternDays[indexInWeek]
const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow)
return toLocalString(occurrenceDate, timeZone)
}
function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat !== 'months') return null
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null
const interval = event.repeatInterval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
const targetMonthOffset = occurrenceIndex * interval
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
// Adjust day for shorter months (clamp like forward logic)
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)
}
function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat === 'none') return null
if (event.repeat === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
if (event.repeat === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
return null
}
function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
const baseStart = fromLocalString(event.startDate, timeZone)
const baseEnd = fromLocalString(event.endDate, timeZone)
@@ -237,6 +299,7 @@ export {
getMondayOfISOWeek,
mondayIndex,
getOccurrenceIndex,
getOccurrenceDate,
getVirtualOccurrenceEndDate,
// formatting & localization
pad,