-
-
+
+
-
-
= 0) {
- const pattern = event.recur.weekdays || []
- const baseStart = fromLocalString(event.startDate, DEFAULT_TZ)
- const baseEnd = new Date(fromLocalString(event.startDate, DEFAULT_TZ))
- baseEnd.setDate(baseEnd.getDate() + (event.days || 1) - 1)
- if (occurrenceIndex === 0) {
- occurrenceDate = baseStart
- weekday = baseStart.getDay()
- } else {
- const interval = event.recur.interval || 1
- 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
- }
- let cur = addDays(baseEnd, 1)
- let found = 0
- let safety = 0
- while (found < occurrenceIndex && safety < 20000) {
- if (pattern[cur.getDay()] && isAligned(cur)) {
- found++
- if (found === occurrenceIndex) break
- }
- cur = addDays(cur, 1)
- safety++
- }
- occurrenceDate = cur
- weekday = cur.getDay()
- }
- } else if (event.recur.freq === 'months' && occurrenceIndex >= 0) {
- const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
- occurrenceDate = addMonths(baseDate, occurrenceIndex)
+ if (event.recur && n >= 0) {
+ const occStr = getOccurrenceDate(event, n, DEFAULT_TZ)
+ if (occStr) {
+ occurrenceDate = fromLocalString(occStr, DEFAULT_TZ)
+ weekday = occurrenceDate.getDay()
}
}
dialogMode.value = 'edit'
@@ -372,10 +343,10 @@ function openEditDialog(payload) {
eventSaved.value = false
if (event.recur) {
- if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) {
- occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
- } else if (event.recur.freq === 'months' && occurrenceIndex > 0) {
- occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate }
+ if (event.recur.freq === 'weeks' && n >= 0) {
+ occurrenceContext.value = { baseId, occurrenceIndex: n, weekday, occurrenceDate }
+ } else if (event.recur.freq === 'months' && n > 0) {
+ occurrenceContext.value = { baseId, occurrenceIndex: n, weekday: null, occurrenceDate }
}
}
// anchor to base event start date
diff --git a/src/components/EventOverlay.vue b/src/components/EventOverlay.vue
index 3de07bb..4a4482b 100644
--- a/src/components/EventOverlay.vue
+++ b/src/components/EventOverlay.vue
@@ -8,12 +8,12 @@
>
diff --git a/src/plugins/virtualWeeks.js b/src/plugins/virtualWeeks.js
index 6ea0550..c68852f 100644
--- a/src/plugins/virtualWeeks.js
+++ b/src/plugins/virtualWeeks.js
@@ -1,5 +1,5 @@
import { ref } from 'vue'
-import { addDays, differenceInWeeks } from 'date-fns'
+import { addDays, differenceInWeeks, isBefore, isAfter } from 'date-fns'
import {
toLocalString,
fromLocalString,
@@ -11,9 +11,8 @@ import {
monthAbbr,
lunarPhaseSymbol,
MAX_YEAR,
- getOccurrenceIndex,
- getVirtualOccurrenceEndDate,
} from '@/utils/date'
+import { buildDayEvents } from '@/utils/events'
import { getHolidayForDate } from '@/utils/holidays'
/**
@@ -54,81 +53,16 @@ export function createVirtualWeekManager({
function createWeek(virtualWeek) {
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
- const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
- const weekNumber = getISOWeek(isoAnchor)
+ const thu = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
+ const weekNumber = getISOWeek(thu)
const days = []
let cur = new Date(firstDay)
let hasFirst = false
let monthToLabel = null
let labelYear = null
-
- const repeatingBases = []
- if (calendarStore.events) {
- for (const ev of calendarStore.events.values()) {
- if (ev.recur) repeatingBases.push(ev)
- }
- }
-
- const collectEventsForDate = (dateStr, curDateObj) => {
- const storedEvents = []
- for (const ev of calendarStore.events.values()) {
- if (!ev.recur) {
- const evEnd = toLocalString(
- addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
- DEFAULT_TZ,
- )
- if (dateStr >= ev.startDate && dateStr <= evEnd) {
- storedEvents.push({ ...ev, endDate: evEnd })
- }
- }
- }
- const dayEvents = [...storedEvents]
- for (const base of repeatingBases) {
- const spanDays = (base.days || 1) - 1
- const currentDate = curDateObj
- const baseEnd = toLocalString(
- addDays(fromLocalString(base.startDate, DEFAULT_TZ), spanDays),
- DEFAULT_TZ,
- )
- for (let offset = 0; offset <= spanDays; offset++) {
- const candidateStart = addDays(currentDate, -offset)
- const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
- if (candidateStartStr === base.startDate) {
- if (dateStr >= base.startDate && dateStr <= baseEnd) {
- if (!dayEvents.some((ev) => ev.id === base.id)) {
- dayEvents.push({
- ...base,
- endDate: baseEnd,
- _recurrenceIndex: 0,
- _baseId: base.id,
- })
- }
- }
- continue
- }
- const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
- if (occurrenceIndex === null) continue
- const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
- if (dateStr < candidateStartStr || dateStr > virtualEndDate) continue
- const virtualId = base.id + '_v_' + candidateStartStr
- if (!dayEvents.some((ev) => ev.id === virtualId)) {
- dayEvents.push({
- ...base,
- id: virtualId,
- startDate: candidateStartStr,
- endDate: virtualEndDate,
- _recurrenceIndex: occurrenceIndex,
- _baseId: base.id,
- })
- }
- }
- }
- return dayEvents
- }
-
for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur, DEFAULT_TZ)
- const dayEvents = collectEventsForDate(dateStr, fromLocalString(dateStr, DEFAULT_TZ))
+ const events = buildDayEvents(calendarStore.events.values(), dateStr, DEFAULT_TZ)
const dow = cur.getDay()
const isFirst = cur.getDate() === 1
if (isFirst) {
@@ -137,10 +71,11 @@ export function createVirtualWeekManager({
labelYear = cur.getFullYear()
}
let displayText = String(cur.getDate())
- if (isFirst) {
- if (cur.getMonth() === 0) displayText = cur.getFullYear()
- else displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
- }
+ if (isFirst)
+ displayText =
+ cur.getMonth() === 0
+ ? cur.getFullYear()
+ : monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase()
let holiday = null
if (calendarStore.config.holidays.enabled) {
calendarStore._ensureHolidaysInitialized?.()
@@ -157,36 +92,12 @@ export function createVirtualWeekManager({
lunarPhase: lunarPhaseSymbol(cur),
holiday,
isHoliday: holiday !== null,
- isSelected:
- selection.value.startDate &&
- selection.value.dayCount > 0 &&
- dateStr >= selection.value.startDate &&
- dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
- events: dayEvents,
+ isSelected: isDateSelected(dateStr),
+ events,
})
cur = addDays(cur, 1)
}
- let monthLabel = null
- if (hasFirst && monthToLabel !== null) {
- if (labelYear && labelYear <= MAX_YEAR) {
- let weeksSpan = 0
- const d = addDays(cur, -1)
- for (let i = 0; i < 6; i++) {
- const probe = addDays(cur, -1 + i * 7)
- d.setTime(probe.getTime())
- if (d.getMonth() === monthToLabel) weeksSpan++
- }
- const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
- weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
- const year = String(labelYear).slice(-2)
- monthLabel = {
- text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
- month: monthToLabel,
- weeksSpan,
- monthClass: monthAbbr[monthToLabel],
- }
- }
- }
+ const monthLabel = buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek })
return {
virtualWeek,
weekNumber: pad(weekNumber),
@@ -196,6 +107,36 @@ export function createVirtualWeekManager({
}
}
+ function isDateSelected(dateStr) {
+ if (!selection.value.startDate || selection.value.dayCount <= 0) return false
+ const startDateObj = fromLocalString(selection.value.startDate, DEFAULT_TZ)
+ const endDateStr = addDaysStr(selection.value.startDate, selection.value.dayCount - 1)
+ const endDateObj = fromLocalString(endDateStr, DEFAULT_TZ)
+ const d = fromLocalString(dateStr, DEFAULT_TZ)
+ return !isBefore(d, startDateObj) && !isAfter(d, endDateObj)
+ }
+
+ function buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek }) {
+ if (!hasFirst || monthToLabel === null) return null
+ if (!labelYear || labelYear > MAX_YEAR) return null
+ let weeksSpan = 0
+ const d = addDays(cur, -1)
+ for (let i = 0; i < 6; i++) {
+ const probe = addDays(cur, -1 + i * 7)
+ d.setTime(probe.getTime())
+ if (d.getMonth() === monthToLabel) weeksSpan++
+ }
+ const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1)
+ weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks))
+ const year = String(labelYear).slice(-2)
+ return {
+ text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
+ month: monthToLabel,
+ weeksSpan,
+ monthClass: monthAbbr[monthToLabel],
+ }
+ }
+
function internalWindowCalc() {
const buffer = 6
const currentScrollTop = viewport.value?.scrollTop ?? scrollTopRef?.value ?? 0
@@ -299,10 +240,6 @@ export function createVirtualWeekManager({
// Reflective update of only events inside currently visible weeks (keeps week objects stable)
function refreshEvents(reason = 'events-refresh') {
if (!visibleWeeks.value.length) return
- const repeatingBases = []
- if (calendarStore.events) {
- for (const ev of calendarStore.events.values()) if (ev.recur) repeatingBases.push(ev)
- }
const selStart = selection.value.startDate
const selCount = selection.value.dayCount
const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null
@@ -310,63 +247,13 @@ export function createVirtualWeekManager({
for (const day of week.days) {
const dateStr = day.date
// Update selection flag
- if (selStart && selEnd) day.isSelected = dateStr >= selStart && dateStr <= selEnd
- else day.isSelected = false
- // Rebuild events list for this day
- const storedEvents = []
- for (const ev of calendarStore.events.values()) {
- if (!ev.recur) {
- const evEnd = toLocalString(
- addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
- DEFAULT_TZ,
- )
- if (dateStr >= ev.startDate && dateStr <= evEnd) {
- storedEvents.push({ ...ev, endDate: evEnd })
- }
- }
- }
- const dayEvents = [...storedEvents]
- for (const base of repeatingBases) {
- const spanDays = (base.days || 1) - 1
- const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
- const baseEndStr = toLocalString(
- addDays(fromLocalString(base.startDate, DEFAULT_TZ), spanDays),
- DEFAULT_TZ,
- )
- for (let offset = 0; offset <= spanDays; offset++) {
- const candidateStart = addDays(currentDate, -offset)
- const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
- if (candidateStartStr === base.startDate) {
- if (dateStr >= base.startDate && dateStr <= baseEndStr) {
- if (!dayEvents.some((ev) => ev.id === base.id)) {
- dayEvents.push({
- ...base,
- endDate: baseEndStr,
- _recurrenceIndex: 0,
- _baseId: base.id,
- })
- }
- }
- continue
- }
- const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
- if (occurrenceIndex === null) continue
- const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
- if (dateStr < candidateStartStr || dateStr > virtualEndDate) continue
- const virtualId = base.id + '_v_' + candidateStartStr
- if (!dayEvents.some((ev) => ev.id === virtualId)) {
- dayEvents.push({
- ...base,
- id: virtualId,
- startDate: candidateStartStr,
- endDate: virtualEndDate,
- _recurrenceIndex: occurrenceIndex,
- _baseId: base.id,
- })
- }
- }
- }
- day.events = dayEvents
+ if (selStart && selEnd) {
+ const d = fromLocalString(dateStr, DEFAULT_TZ),
+ s = fromLocalString(selStart, DEFAULT_TZ),
+ e = fromLocalString(selEnd, DEFAULT_TZ)
+ day.isSelected = !isBefore(d, s) && !isAfter(d, e)
+ } else day.isSelected = false
+ day.events = buildDayEvents(dateStr)
}
}
if (process.env.NODE_ENV !== 'production') {
diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js
index 5b5adab..04a25ce 100644
--- a/src/stores/CalendarStore.js
+++ b/src/stores/CalendarStore.js
@@ -4,7 +4,6 @@ import {
fromLocalString,
getLocaleWeekendDays,
getMondayOfISOWeek,
- getOccurrenceDate,
DEFAULT_TZ,
} from '@/utils/date'
import { differenceInCalendarDays, addDays } from 'date-fns'
@@ -201,11 +200,6 @@ export const useCalendarStore = defineStore('calendar', {
this.deleteEvent(baseId)
return
}
- const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ)
- if (!nextStartStr) {
- this.deleteEvent(baseId)
- return
- }
base.startDate = nextStartStr
// keep same days length
if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1))
@@ -228,9 +222,11 @@ export const useCalendarStore = defineStore('calendar', {
}
const snapshot = { ...base }
snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
+ if (base.recur.count === occurrenceIndex + 1) {
+ base.recur.count = occurrenceIndex
+ return
+ }
base.recur.count = occurrenceIndex
- const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
- if (!nextStartStr) return
const originalNumeric =
snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
let remainingCount = 'unlimited'
diff --git a/src/utils/date.js b/src/utils/date.js
index 863ec90..50dfa0a 100644
--- a/src/utils/date.js
+++ b/src/utils/date.js
@@ -23,7 +23,8 @@ const monthAbbr = [
'nov',
'dec',
]
-const MIN_YEAR = 100 // less than 100 is interpreted as 19xx
+// Browser safe range
+const MIN_YEAR = 100
const MAX_YEAR = 9999
// Core helpers ------------------------------------------------------------
@@ -70,169 +71,7 @@ function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) {
const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7
-// Count how many days in [startDate..endDate] match the boolean `pattern` array
-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)
-}
-
-// Recurrence: Weekly ------------------------------------------------------
-function _getRecur(event) {
- return event?.recur ?? null
-}
-
-function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
- const recur = _getRecur(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 (target < baseStart) return null
-
- const dow = dateFns.getDay(target)
- if (!pattern[dow]) return null // target not active
-
- const interval = recur.interval || 1
- const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
- const currentBlockStart = getMondayOfISOWeek(target, timeZone)
- // Number of weeks between block starts (each block start is a Monday)
- const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart)
- if (weekDiff < 0 || weekDiff % interval !== 0) return null
-
- const baseDow = dateFns.getDay(baseStart)
- const baseCountsAsPattern = !!pattern[baseDow]
-
- // Same ISO week as base: count pattern days from baseStart up to target (inclusive)
- 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)
- // Count pattern days in the first (possibly partial) week from baseStart..baseWeekEnd
- const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern)
- const alignedWeeksBetween = weekDiff / interval - 1
- const fullPatternWeekCount = pattern.filter(Boolean).length
- const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0
- // Count pattern days in the current (possibly partial) week from currentBlockStart..target
- 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
-}
-
-// Recurrence: Monthly -----------------------------------------------------
-function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
- const recur = _getRecur(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 getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
- const recur = _getRecur(event)
- if (!recur) return null
- if (dateStr < event.startDate) return null
- if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
- if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
- 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) {
- const recur = _getRecur(event)
- if (!recur || recur.freq !== 'weeks') return null
- if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
- const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
- if (occurrenceIndex >= 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 (occurrenceIndex === 0) return toLocalString(baseStart, timeZone)
- const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
- const baseDow = dateFns.getDay(baseStart)
- const baseCountsAsPattern = !!pattern[baseDow]
- // Adjust index if base weekday is not part of the pattern (pattern occurrences shift by +1)
- let occ = occurrenceIndex
- if (!baseCountsAsPattern) occ -= 1
- if (occ < 0) return null
- // 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 (occ < F) {
- return toLocalString(firstWeekDates[occ], timeZone)
- }
- const remaining = occ - 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) {
- const recur = _getRecur(event)
- if (!recur || recur.freq !== 'months') return null
- if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
- const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
- if (occurrenceIndex >= maxCount) return null
- const interval = recur.interval || 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) {
- const recur = _getRecur(event)
- if (!recur) return null
- if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
- if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
- return null
-}
-
-function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
- const spanDays = Math.max(0, (event.days || 1) - 1)
- const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone)
- return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone)
-}
+// (Recurrence utilities moved to events.js)
// Utility formatting & localization ---------------------------------------
const pad = (n) => String(n).padStart(2, '0')
@@ -366,9 +205,6 @@ export {
// recurrence
getMondayOfISOWeek,
mondayIndex,
- getOccurrenceIndex,
- getOccurrenceDate,
- getVirtualOccurrenceEndDate,
// formatting & localization
pad,
daysInclusive,
diff --git a/src/utils/events.js b/src/utils/events.js
new file mode 100644
index 0000000..bc79b4b
--- /dev/null
+++ b/src/utils/events.js
@@ -0,0 +1,171 @@
+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
+ const patternDays = []
+ for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d)
+ 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 (occ < F) return toLocalString(firstWeekDates[occ], timeZone)
+ const remaining = occ - F
+ const P = patternDays.length
+ if (P === 0) return null
+ const k = Math.floor(remaining / P) + 1
+ const indexInWeek = remaining % P
+ const dow = patternDays[indexInWeek]
+ const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow)
+ return toLocalString(occurrenceDate, 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
+}
+
+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)
+}