Major new version #2
@ -5,9 +5,10 @@ import {
|
|||||||
getLocaleWeekendDays,
|
getLocaleWeekendDays,
|
||||||
getMondayOfISOWeek,
|
getMondayOfISOWeek,
|
||||||
getOccurrenceIndex,
|
getOccurrenceIndex,
|
||||||
|
getOccurrenceDate,
|
||||||
DEFAULT_TZ,
|
DEFAULT_TZ,
|
||||||
} from '@/utils/date'
|
} from '@/utils/date'
|
||||||
import { differenceInCalendarDays, addDays, addMonths } from 'date-fns'
|
import { differenceInCalendarDays, addDays } from 'date-fns'
|
||||||
import {
|
import {
|
||||||
initializeHolidays,
|
initializeHolidays,
|
||||||
getHolidayForDate,
|
getHolidayForDate,
|
||||||
@ -235,249 +236,100 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteEvent(eventId) {
|
deleteEvent(eventId) {
|
||||||
|
console.log('Deleting event', eventId)
|
||||||
this.events.delete(eventId)
|
this.events.delete(eventId)
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteSingleOccurrence(ctx) {
|
// Remove the first (base) occurrence of a repeating event by shifting anchor forward
|
||||||
const { baseId, occurrenceIndex } = ctx
|
deleteFirstOccurrence(baseId) {
|
||||||
|
console.log('Deleting first occurrence', baseId)
|
||||||
const base = this.getEventById(baseId)
|
const base = this.getEventById(baseId)
|
||||||
if (!base || !base.isRepeating) return
|
if (!base) return
|
||||||
// WEEKLY SERIES ------------------------------------------------------
|
if (!base.isRepeating) {
|
||||||
if (base.repeat === 'weeks') {
|
// Simple (non-repeating) event: delete entirely
|
||||||
// Special case: deleting the first occurrence (index 0) should shift the series forward
|
this.deleteEvent(baseId)
|
||||||
if (occurrenceIndex === 0) {
|
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
|
||||||
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
|
||||||
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
|
||||||
const pattern = base.repeatWeekdays || []
|
|
||||||
if (!pattern.some(Boolean)) {
|
|
||||||
// No pattern to continue -> delete whole series
|
|
||||||
this.deleteEvent(baseId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const interval = base.repeatInterval || 1
|
|
||||||
const WEEK_MS = 7 * 86400000
|
|
||||||
const baseBlockStart = getMondayOfISOWeek(baseStart)
|
|
||||||
const isAligned = (d) => {
|
|
||||||
const blk = getMondayOfISOWeek(d)
|
|
||||||
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
|
|
||||||
return diff % interval === 0
|
|
||||||
}
|
|
||||||
let probe = new Date(baseStart)
|
|
||||||
let safety = 0
|
|
||||||
let found = null
|
|
||||||
while (safety < 5000) {
|
|
||||||
probe = addDays(probe, 1)
|
|
||||||
if (pattern[probe.getDay()] && isAligned(probe)) {
|
|
||||||
found = new Date(probe)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
safety++
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
// Nothing after first -> delete series
|
|
||||||
this.deleteEvent(baseId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Adjust repeat count
|
|
||||||
if (base.repeatCount !== 'unlimited') {
|
|
||||||
const rc = parseInt(base.repeatCount, 10)
|
|
||||||
if (!isNaN(rc)) {
|
|
||||||
const newRc = rc - 1
|
|
||||||
if (newRc <= 0) {
|
|
||||||
this.deleteEvent(baseId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
base.repeatCount = String(newRc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newEnd = addDays(found, spanDays)
|
|
||||||
base.startDate = toLocalString(found, DEFAULT_TZ)
|
|
||||||
base.endDate = toLocalString(newEnd, DEFAULT_TZ)
|
|
||||||
base.isSpanning = base.startDate < base.endDate
|
|
||||||
this.events.set(base.id, base)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const interval = base.repeatInterval || 1
|
|
||||||
const pattern = base.repeatWeekdays || []
|
|
||||||
if (!pattern.some(Boolean)) return
|
|
||||||
// Preserve original count before any truncation
|
|
||||||
const originalCountRaw = base.repeatCount
|
|
||||||
|
|
||||||
// Determine target occurrence date
|
|
||||||
let targetDate = null
|
|
||||||
if (ctx.occurrenceDate instanceof Date) {
|
|
||||||
targetDate = new Date(
|
|
||||||
ctx.occurrenceDate.getFullYear(),
|
|
||||||
ctx.occurrenceDate.getMonth(),
|
|
||||||
ctx.occurrenceDate.getDate(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Fallback: derive from sequential occurrenceIndex (base=0, first repeat=1)
|
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
|
||||||
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
|
||||||
if (occurrenceIndex === 0) {
|
|
||||||
targetDate = baseStart
|
|
||||||
} else {
|
|
||||||
let cur = new Date(baseEnd)
|
|
||||||
cur = addDays(cur, 1)
|
|
||||||
let found = 0
|
|
||||||
let safety = 0
|
|
||||||
const WEEK_MS = 7 * 86400000
|
|
||||||
const baseBlockStart = getMondayOfISOWeek(baseStart)
|
|
||||||
function isAligned(d) {
|
|
||||||
const blk = getMondayOfISOWeek(d)
|
|
||||||
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
|
|
||||||
return diff % interval === 0
|
|
||||||
}
|
|
||||||
while (found < occurrenceIndex && safety < 50000) {
|
|
||||||
if (pattern[cur.getDay()] && isAligned(cur)) {
|
|
||||||
found++
|
|
||||||
if (found === occurrenceIndex) break
|
|
||||||
}
|
|
||||||
cur = addDays(cur, 1)
|
|
||||||
safety++
|
|
||||||
}
|
|
||||||
targetDate = cur
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!targetDate) return
|
|
||||||
|
|
||||||
// Count occurrences BEFORE target (always include the base occurrence as first)
|
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
|
||||||
const baseBlockStart = getMondayOfISOWeek(baseStart)
|
|
||||||
const WEEK_MS = 7 * 86400000
|
|
||||||
function isAligned(d) {
|
|
||||||
const block = getMondayOfISOWeek(d)
|
|
||||||
const diff = Math.floor((block - baseBlockStart) / WEEK_MS)
|
|
||||||
return diff % interval === 0
|
|
||||||
}
|
|
||||||
// Start with 1 (base occurrence) if target is after base; if target IS base (should not happen here) leave 0
|
|
||||||
let countBefore = targetDate > baseStart ? 1 : 0
|
|
||||||
let probe = new Date(baseStart)
|
|
||||||
probe = addDays(probe, 1) // start counting AFTER base
|
|
||||||
let safety2 = 0
|
|
||||||
while (probe < targetDate && safety2 < 50000) {
|
|
||||||
if (pattern[probe.getDay()] && isAligned(probe)) countBefore++
|
|
||||||
probe = addDays(probe, 1)
|
|
||||||
safety2++
|
|
||||||
}
|
|
||||||
// Terminate original series to keep only occurrences before target
|
|
||||||
this._terminateRepeatSeriesAtIndex(baseId, countBefore)
|
|
||||||
|
|
||||||
// Calculate remaining occurrences for new series using ORIGINAL total
|
|
||||||
let remainingCount = 'unlimited'
|
|
||||||
if (originalCountRaw !== 'unlimited') {
|
|
||||||
const originalTotal = parseInt(originalCountRaw, 10)
|
|
||||||
if (!isNaN(originalTotal)) {
|
|
||||||
const rem = originalTotal - countBefore - 1 // kept + deleted
|
|
||||||
if (rem <= 0) return // nothing left to continue
|
|
||||||
remainingCount = String(rem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continuation starts at NEXT valid occurrence (matching weekday & aligned block)
|
|
||||||
let continuationStart = new Date(targetDate)
|
|
||||||
let searchSafety = 0
|
|
||||||
let foundNext = false
|
|
||||||
while (searchSafety < 50000) {
|
|
||||||
continuationStart = addDays(continuationStart, 1)
|
|
||||||
if (pattern[continuationStart.getDay()] && isAligned(continuationStart)) {
|
|
||||||
foundNext = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
searchSafety++
|
|
||||||
}
|
|
||||||
if (!foundNext) return // no remaining occurrences
|
|
||||||
|
|
||||||
const spanDays = differenceInCalendarDays(
|
|
||||||
fromLocalString(base.endDate, DEFAULT_TZ),
|
|
||||||
fromLocalString(base.startDate, DEFAULT_TZ),
|
|
||||||
)
|
|
||||||
const nextStartStr = toLocalString(continuationStart, DEFAULT_TZ)
|
|
||||||
const nextEnd = addDays(continuationStart, spanDays)
|
|
||||||
const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ)
|
|
||||||
this.createEvent({
|
|
||||||
title: base.title,
|
|
||||||
startDate: nextStartStr,
|
|
||||||
endDate: nextEndStr,
|
|
||||||
colorId: base.colorId,
|
|
||||||
repeat: 'weeks',
|
|
||||||
repeatInterval: interval,
|
|
||||||
repeatCount: remainingCount,
|
|
||||||
repeatWeekdays: base.repeatWeekdays,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// MONTHLY SERIES -----------------------------------------------------
|
const numericCount =
|
||||||
if (base.repeat === 'months') {
|
base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10)
|
||||||
if (occurrenceIndex === 0) {
|
if (numericCount <= 1) {
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
// Only one occurrence (or invalid count) -> delete event
|
||||||
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
this.deleteEvent(baseId)
|
||||||
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
return
|
||||||
const interval = base.repeatInterval || 1
|
|
||||||
const targetMonthIndex = baseStart.getMonth() + interval
|
|
||||||
const targetYear = baseStart.getFullYear() + Math.floor(targetMonthIndex / 12)
|
|
||||||
const targetMonth = targetMonthIndex % 12
|
|
||||||
const daysInTarget = new Date(targetYear, targetMonth + 1, 0).getDate()
|
|
||||||
const dom = Math.min(baseStart.getDate(), daysInTarget)
|
|
||||||
const newStart = new Date(targetYear, targetMonth, dom)
|
|
||||||
if (base.repeatCount !== 'unlimited') {
|
|
||||||
const rc = parseInt(base.repeatCount, 10)
|
|
||||||
if (!isNaN(rc)) {
|
|
||||||
const newRc = rc - 1
|
|
||||||
if (newRc <= 0) {
|
|
||||||
this.deleteEvent(baseId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
base.repeatCount = String(newRc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newEnd = addDays(newStart, spanDays)
|
|
||||||
base.startDate = toLocalString(newStart, DEFAULT_TZ)
|
|
||||||
base.endDate = toLocalString(newEnd, DEFAULT_TZ)
|
|
||||||
base.isSpanning = base.startDate < base.endDate
|
|
||||||
this.events.set(base.id, base)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const interval = base.repeatInterval || 1
|
|
||||||
// Sequential index: base=0, first repeat=1
|
|
||||||
if (occurrenceIndex <= 0) return // base deletion handled elsewhere
|
|
||||||
// Count prior occurrences to KEEP (indices 0 .. occurrenceIndex-1) => occurrenceIndex total
|
|
||||||
const originalCountRaw = base.repeatCount
|
|
||||||
const priorOccurrences = occurrenceIndex
|
|
||||||
this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences)
|
|
||||||
// Compute span days for multi‑day events
|
|
||||||
const spanDays = differenceInCalendarDays(
|
|
||||||
fromLocalString(base.endDate, DEFAULT_TZ),
|
|
||||||
fromLocalString(base.startDate, DEFAULT_TZ),
|
|
||||||
)
|
|
||||||
// Remaining occurrences after deletion
|
|
||||||
let remainingCount = 'unlimited'
|
|
||||||
if (originalCountRaw !== 'unlimited') {
|
|
||||||
const total = parseInt(originalCountRaw, 10)
|
|
||||||
if (!isNaN(total)) {
|
|
||||||
const rem = total - priorOccurrences - 1 // subtract kept + deleted
|
|
||||||
if (rem <= 0) return // nothing left
|
|
||||||
remainingCount = String(rem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Next occurrence after deleted one is at (occurrenceIndex + 1)*interval months from base
|
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
|
||||||
const nextStart = addMonths(baseStart, (occurrenceIndex + 1) * interval)
|
|
||||||
const nextEnd = addDays(nextStart, spanDays)
|
|
||||||
const nextStartStr = toLocalString(nextStart, DEFAULT_TZ)
|
|
||||||
const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ)
|
|
||||||
this.createEvent({
|
|
||||||
title: base.title,
|
|
||||||
startDate: nextStartStr,
|
|
||||||
endDate: nextEndStr,
|
|
||||||
colorId: base.colorId,
|
|
||||||
repeat: 'months',
|
|
||||||
repeatInterval: interval,
|
|
||||||
repeatCount: remainingCount,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
// Get the next occurrence start date (index 1)
|
||||||
|
const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ)
|
||||||
|
if (!nextStartStr) {
|
||||||
|
// No next occurrence; remove event
|
||||||
|
this.deleteEvent(baseId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const oldStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
|
const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
||||||
|
const durationDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart))
|
||||||
|
const newEndStr = toLocalString(
|
||||||
|
addDays(fromLocalString(nextStartStr, DEFAULT_TZ), durationDays),
|
||||||
|
DEFAULT_TZ,
|
||||||
|
)
|
||||||
|
// Mutate existing event instead of delete+recreate so references remain stable
|
||||||
|
base.startDate = nextStartStr
|
||||||
|
base.endDate = newEndStr
|
||||||
|
if (numericCount !== Infinity) {
|
||||||
|
base.repeatCount = String(Math.max(1, numericCount - 1))
|
||||||
|
}
|
||||||
|
this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete a specific occurrence (not the first) from a repeating series, splitting into two
|
||||||
|
deleteSingleOccurrence(ctx) {
|
||||||
|
console.log('DeletesingleOccurrence')
|
||||||
|
const { baseId, occurrenceIndex } = ctx || {}
|
||||||
|
if (occurrenceIndex === undefined || occurrenceIndex === null) return
|
||||||
|
const base = this.getEventById(baseId)
|
||||||
|
if (!base) return
|
||||||
|
if (!base.isRepeating) {
|
||||||
|
// Single non-repeating event deletion
|
||||||
|
if (occurrenceIndex === 0) this.deleteEvent(baseId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (occurrenceIndex === 0) {
|
||||||
|
// Delegate to specialized first-occurrence deletion
|
||||||
|
this.deleteFirstOccurrence(baseId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Save copy before truncation for computing next occurrence date
|
||||||
|
const snapshot = { ...base }
|
||||||
|
// Cap original series to occurrences before the deleted one
|
||||||
|
base.repeatCount = occurrenceIndex
|
||||||
|
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
|
||||||
|
console.log('Deleting single', occurrenceIndex, nextStartStr)
|
||||||
|
if (!nextStartStr) return // no continuation
|
||||||
|
const durationDays = Math.max(
|
||||||
|
0,
|
||||||
|
differenceInCalendarDays(
|
||||||
|
fromLocalString(snapshot.endDate),
|
||||||
|
fromLocalString(snapshot.startDate),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const newEndStr = toLocalString(addDays(fromLocalString(nextStartStr), durationDays))
|
||||||
|
const originalNumeric =
|
||||||
|
snapshot.repeatCount === 'unlimited' ? Infinity : parseInt(snapshot.repeatCount, 10)
|
||||||
|
let remainingCount = 'unlimited'
|
||||||
|
if (originalNumeric !== Infinity) {
|
||||||
|
const rem = originalNumeric - (occurrenceIndex + 1)
|
||||||
|
if (rem <= 0) return
|
||||||
|
remainingCount = String(rem)
|
||||||
|
}
|
||||||
|
this.createEvent({
|
||||||
|
title: snapshot.title,
|
||||||
|
startDate: nextStartStr,
|
||||||
|
endDate: newEndStr,
|
||||||
|
colorId: snapshot.colorId,
|
||||||
|
repeat: snapshot.repeat,
|
||||||
|
repeatInterval: snapshot.repeatInterval,
|
||||||
|
repeatCount: remainingCount,
|
||||||
|
repeatWeekdays: snapshot.repeatWeekdays,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteFromOccurrence(ctx) {
|
deleteFromOccurrence(ctx) {
|
||||||
@ -494,86 +346,6 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
this._terminateRepeatSeriesAtIndex(baseId, keptTotal)
|
this._terminateRepeatSeriesAtIndex(baseId, keptTotal)
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteFirstOccurrence(baseId) {
|
|
||||||
const base = this.getEventById(baseId)
|
|
||||||
if (!base || !base.isRepeating) return
|
|
||||||
const oldStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
|
||||||
const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
|
||||||
const spanDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart))
|
|
||||||
|
|
||||||
let newStartDate = null
|
|
||||||
|
|
||||||
if (base.repeat === 'weeks') {
|
|
||||||
const pattern = base.repeatWeekdays || []
|
|
||||||
if (!pattern.some(Boolean)) {
|
|
||||||
// No valid pattern -> delete series
|
|
||||||
this.deleteEvent(baseId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const interval = base.repeatInterval || 1
|
|
||||||
const baseBlockStart = getMondayOfISOWeek(oldStart)
|
|
||||||
const WEEK_MS = 7 * 86400000
|
|
||||||
const isAligned = (d) => {
|
|
||||||
const block = getMondayOfISOWeek(d)
|
|
||||||
const diff = Math.floor((block - baseBlockStart) / WEEK_MS)
|
|
||||||
return diff % interval === 0
|
|
||||||
}
|
|
||||||
// search forward for next valid weekday respecting interval alignment
|
|
||||||
let probe = new Date(oldStart)
|
|
||||||
let safety = 0
|
|
||||||
while (safety < 5000) {
|
|
||||||
probe = addDays(probe, 1)
|
|
||||||
if (pattern[probe.getDay()] && isAligned(probe)) {
|
|
||||||
newStartDate = new Date(probe)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
safety++
|
|
||||||
}
|
|
||||||
} else if (base.repeat === 'months') {
|
|
||||||
const interval = base.repeatInterval || 1
|
|
||||||
const y = oldStart.getFullYear()
|
|
||||||
const m = oldStart.getMonth()
|
|
||||||
const targetMonthIndex = m + interval
|
|
||||||
const targetYear = y + Math.floor(targetMonthIndex / 12)
|
|
||||||
const targetMonth = targetMonthIndex % 12
|
|
||||||
const daysInTargetMonth = new Date(targetYear, targetMonth + 1, 0).getDate()
|
|
||||||
const dom = Math.min(oldStart.getDate(), daysInTargetMonth)
|
|
||||||
newStartDate = new Date(targetYear, targetMonth, dom)
|
|
||||||
} else {
|
|
||||||
// Unsupported repeat type
|
|
||||||
this.deleteEvent(baseId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newStartDate) {
|
|
||||||
// No continuation; deleting first removes series
|
|
||||||
this.deleteEvent(baseId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrement repeatCount if limited
|
|
||||||
if (base.repeatCount !== 'unlimited') {
|
|
||||||
const rc = parseInt(base.repeatCount, 10)
|
|
||||||
if (!isNaN(rc)) {
|
|
||||||
const newRc = rc - 1
|
|
||||||
if (newRc <= 0) {
|
|
||||||
// After removing first occurrence there are none left
|
|
||||||
this.deleteEvent(baseId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
base.repeatCount = String(newRc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEndDate = addDays(newStartDate, spanDays)
|
|
||||||
base.startDate = toLocalString(newStartDate, DEFAULT_TZ)
|
|
||||||
base.endDate = toLocalString(newEndDate, DEFAULT_TZ)
|
|
||||||
base.isSpanning = base.startDate < base.endDate
|
|
||||||
// Persist updated base event
|
|
||||||
this.events.set(base.id, base)
|
|
||||||
return base.id
|
|
||||||
},
|
|
||||||
|
|
||||||
// Adjust start/end range of a base event (non-generated) and reindex occurrences
|
// Adjust start/end range of a base event (non-generated) and reindex occurrences
|
||||||
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
|
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
|
||||||
const snapshot = this.events.get(eventId)
|
const snapshot = this.events.get(eventId)
|
||||||
|
@ -139,6 +139,68 @@ function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
|
|||||||
return null
|
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) {
|
function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
|
||||||
const baseStart = fromLocalString(event.startDate, timeZone)
|
const baseStart = fromLocalString(event.startDate, timeZone)
|
||||||
const baseEnd = fromLocalString(event.endDate, timeZone)
|
const baseEnd = fromLocalString(event.endDate, timeZone)
|
||||||
@ -237,6 +299,7 @@ export {
|
|||||||
getMondayOfISOWeek,
|
getMondayOfISOWeek,
|
||||||
mondayIndex,
|
mondayIndex,
|
||||||
getOccurrenceIndex,
|
getOccurrenceIndex,
|
||||||
|
getOccurrenceDate,
|
||||||
getVirtualOccurrenceEndDate,
|
getVirtualOccurrenceEndDate,
|
||||||
// formatting & localization
|
// formatting & localization
|
||||||
pad,
|
pad,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user