Major new version #2

Merged
LeoVasanko merged 86 commits from vol002 into main 2025-08-26 05:58:24 +01:00
4 changed files with 345 additions and 216 deletions
Showing only changes of commit 8caaf33cb9 - Show all commits

View File

@ -13,6 +13,9 @@ import {
daysInclusive, daysInclusive,
addDaysStr, addDaysStr,
formatDateRange, formatDateRange,
getMondayOfISOWeek,
getOccurrenceIndex,
getVirtualOccurrenceEndDate,
} from '@/utils/date' } from '@/utils/date'
import { toLocalString, fromLocalString } from '@/utils/date' import { toLocalString, fromLocalString } from '@/utils/date'
@ -153,25 +156,11 @@ function createWeek(virtualWeek) {
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur) const dateStr = toLocalString(cur)
const storedEvents = [] const storedEvents = []
const idSet = calendarStore.dates.get(dateStr)
if (idSet) { // Find all non-repeating events that occur on this date
// Support Set or Array; ignore unexpected shapes for (const ev of calendarStore.events.values()) {
if (idSet instanceof Set) { if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
idSet.forEach((id) => { storedEvents.push(ev)
const ev = calendarStore.events.get(id)
if (ev) storedEvents.push(ev)
})
} else if (Array.isArray(idSet)) {
for (const id of idSet) {
const ev = calendarStore.events.get(id)
if (ev) storedEvents.push(ev)
}
} else if (typeof idSet === 'object' && idSet !== null) {
// If mistakenly hydrated as plain object {id:true,...}
for (const id of Object.keys(idSet)) {
const ev = calendarStore.events.get(id)
if (ev) storedEvents.push(ev)
}
} }
} }
// Build day events starting with stored (base/spanning) then virtual occurrences // Build day events starting with stored (base/spanning) then virtual occurrences
@ -179,56 +168,45 @@ function createWeek(virtualWeek) {
for (const base of repeatingBases) { for (const base of repeatingBases) {
// Skip if the base itself already on this date (already in storedEvents) // Skip if the base itself already on this date (already in storedEvents)
if (dateStr >= base.startDate && dateStr <= base.endDate) continue if (dateStr >= base.startDate && dateStr <= base.endDate) continue
if (calendarStore.occursOnDate(base, dateStr)) {
// Determine sequential occurrence index: base event = 0, first repeat = 1, etc. // Check if any occurrence of this repeating event spans through this date
let recurrenceIndex = 0 const baseStart = fromLocalString(base.startDate)
try { const baseEnd = fromLocalString(base.endDate)
if (base.repeat === 'weeks') { const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000)))
const pattern = base.repeatWeekdays || [] const currentDate = fromLocalString(dateStr)
const interval = base.repeatInterval || 1
const baseStart = new Date(base.startDate + 'T00:00:00') let occurrenceFound = false
const baseEnd = new Date(base.endDate + 'T00:00:00')
const target = new Date(dateStr + 'T00:00:00') // Check dates going backwards to find an occurrence that might span to this date
const WEEK_MS = 7 * 86400000 for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
const baseBlockStart = new Date(baseStart) const candidateStart = new Date(currentDate)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay()) candidateStart.setDate(candidateStart.getDate() - offset)
function isAligned(d) { const candidateStartStr = toLocalString(candidateStart)
const blk = new Date(d)
blk.setDate(d.getDate() - d.getDay()) const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr)
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) if (occurrenceIndex !== null) {
return diff % interval === 0 // Calculate the end date of this occurrence
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr)
// Check if this occurrence spans through the current date
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
// Create virtual occurrence (if not already created)
const virtualId = base.id + '_v_' + candidateStartStr
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
if (!alreadyExists) {
dayEvents.push({
...base,
id: virtualId,
startDate: candidateStartStr,
endDate: virtualEndDate,
_recurrenceIndex: occurrenceIndex,
_baseId: base.id,
})
} }
// Count valid occurrences after base end and before target occurrenceFound = true
let count = 0
const cursor = new Date(baseEnd)
cursor.setDate(cursor.getDate() + 1)
while (cursor < target) {
if (pattern[cursor.getDay()] && isAligned(cursor)) count++
cursor.setDate(cursor.getDate() + 1)
}
// Target itself is guaranteed valid (occursOnDate passed), so its index is count+1
recurrenceIndex = count + 1
} else if (base.repeat === 'months') {
const baseStart = new Date(base.startDate + 'T00:00:00')
const target = new Date(dateStr + 'T00:00:00')
const interval = base.repeatInterval || 1
const diffMonths =
(target.getFullYear() - baseStart.getFullYear()) * 12 +
(target.getMonth() - baseStart.getMonth())
// diffMonths should be multiple of interval; sequential index = diffMonths/interval
recurrenceIndex = diffMonths / interval
} }
} catch {
recurrenceIndex = 0
} }
dayEvents.push({
...base,
id: base.id + '_v_' + dateStr,
startDate: dateStr,
endDate: dateStr,
_recurrenceIndex: recurrenceIndex,
_baseId: base.id,
})
} }
} }
const dow = cur.getDay() const dow = cur.getDay()

View File

@ -3,7 +3,7 @@ import { useCalendarStore } from '@/stores/CalendarStore'
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import WeekdaySelector from './WeekdaySelector.vue' import WeekdaySelector from './WeekdaySelector.vue'
import Numeric from './Numeric.vue' import Numeric from './Numeric.vue'
import { addDaysStr } from '@/utils/date' import { addDaysStr, getMondayOfISOWeek } from '@/utils/date'
const props = defineProps({ const props = defineProps({
selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) }, selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) },
@ -173,11 +173,9 @@ function openEditDialog(payload) {
// Count valid repeat occurrences (pattern + interval alignment) AFTER the base span // Count valid repeat occurrences (pattern + interval alignment) AFTER the base span
const interval = event.repeatInterval || 1 const interval = event.repeatInterval || 1
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
const baseBlockStart = new Date(baseStart) const baseBlockStart = getMondayOfISOWeek(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
function isAligned(d) { function isAligned(d) {
const blk = new Date(d) const blk = getMondayOfISOWeek(d)
blk.setDate(d.getDate() - d.getDay())
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
return diff % interval === 0 return diff % interval === 0
} }

View File

@ -1,5 +1,15 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { toLocalString, fromLocalString, getLocaleWeekendDays } from '@/utils/date' import {
toLocalString,
fromLocalString,
getLocaleWeekendDays,
getMondayOfISOWeek,
getOccurrenceIndex,
getWeeklyOccurrenceIndex,
getMonthlyOccurrenceIndex,
getVirtualOccurrenceEndDate,
occursOnOrSpansDate,
} from '@/utils/date'
const MIN_YEAR = 1900 const MIN_YEAR = 1900
const MAX_YEAR = 2100 const MAX_YEAR = 2100
@ -9,7 +19,6 @@ export const useCalendarStore = defineStore('calendar', {
today: toLocalString(new Date()), today: toLocalString(new Date()),
now: new Date().toISOString(), // store as ISO string now: new Date().toISOString(), // store as ISO string
events: new Map(), // id -> event object (primary) events: new Map(), // id -> event object (primary)
dates: new Map(), // dateStr -> Set of event ids
weekend: getLocaleWeekendDays(), weekend: getLocaleWeekendDays(),
config: { config: {
select_days: 1000, select_days: 1000,
@ -28,74 +37,7 @@ export const useCalendarStore = defineStore('calendar', {
actions: { actions: {
// Determine if a repeating event occurs on a specific date (YYYY-MM-DD) without iterating all occurrences. // Determine if a repeating event occurs on a specific date (YYYY-MM-DD) without iterating all occurrences.
occursOnDate(event, dateStr) { occursOnDate(event, dateStr) {
if (!event || !event.isRepeating || event.repeat === 'none') return false return getOccurrenceIndex(event, dateStr) !== null
// Quick bounds: event cannot occur before its base start
if (dateStr < event.startDate) return false
// For multi-day spanning events, we treat start date as anchor; UI handles span painting separately.
if (event.repeat === 'weeks') {
const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return false
// Day of week must match
const d = fromLocalString(dateStr)
const dow = d.getDay()
if (!pattern[dow]) return false
// Compute week distance blocks respecting interval by counting ISO weeks since anchor Monday of base.
const baseStart = fromLocalString(event.startDate)
// If date is before base anchor weekday match, ensure anchor alignment
// Count days since base start; ensure that number of matching weekdays encountered equals occurrence index < repeatCount
// Optimized approach: approximate max occurrences cap first.
const interval = event.repeatInterval || 1
// Check if date resides in a week block that aligns with interval
const baseBlockStart = new Date(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
const currentBlockStart = new Date(d)
currentBlockStart.setDate(d.getDate() - d.getDay())
const WEEK_MS = 7 * 86400000
const blocksDiff = Math.floor((currentBlockStart - baseBlockStart) / WEEK_MS)
if (blocksDiff < 0 || blocksDiff % interval !== 0) return false
// Count occurrences up to this date to enforce repeatCount finite limits
if (event.repeatCount !== 'unlimited') {
const targetTime = d.getTime()
let occs = 0
const cursor = new Date(baseStart)
const limit = parseInt(event.repeatCount, 10)
const safetyLimit = Math.min(limit + 1, 10000)
while (cursor.getTime() <= targetTime && occs < safetyLimit) {
if (pattern[cursor.getDay()]) {
if (cursor.getTime() === targetTime) {
// This is the occurrence. Validate occs < limit
return occs < limit
}
occs++
}
cursor.setDate(cursor.getDate() + 1)
}
return false
}
return true
} else if (event.repeat === 'months') {
const baseStart = fromLocalString(event.startDate)
const d = fromLocalString(dateStr)
const diffMonths =
(d.getFullYear() - baseStart.getFullYear()) * 12 + (d.getMonth() - baseStart.getMonth())
if (diffMonths < 0) return false
const interval = event.repeatInterval || 1
if (diffMonths % interval !== 0) return false
// Check day match (clamped for shorter months). Base day might exceed target month length.
const baseDay = baseStart.getDate()
const daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
const effectiveDay = Math.min(baseDay, daysInMonth)
if (d.getDate() !== effectiveDay) return false
if (event.repeatCount !== 'unlimited') {
const limit = parseInt(event.repeatCount, 10)
if (isNaN(limit)) return false
// Base is occurrence 0; diffMonths/interval gives occurrence index
const occurrenceIndex = diffMonths / interval
return occurrenceIndex < limit
}
return true
}
return false
}, },
updateCurrentDate() { updateCurrentDate() {
const d = new Date() const d = new Date()
@ -136,7 +78,6 @@ export const useCalendarStore = defineStore('calendar', {
} }
this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate }) this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
this._indexEventDates(event.id)
return event.id return event.id
}, },
@ -170,14 +111,6 @@ export const useCalendarStore = defineStore('calendar', {
}, },
deleteEvent(eventId) { deleteEvent(eventId) {
if (!this.events.has(eventId)) return
// Remove id from all date sets
for (const [dateStr, set] of this.dates) {
if (set.has(eventId)) {
set.delete(eventId)
if (set.size === 0) this.dates.delete(dateStr)
}
}
this.events.delete(eventId) this.events.delete(eventId)
}, },
@ -213,11 +146,9 @@ export const useCalendarStore = defineStore('calendar', {
let found = 0 let found = 0
let safety = 0 let safety = 0
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
const baseBlockStart = new Date(baseStart) const baseBlockStart = getMondayOfISOWeek(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
function isAligned(d) { function isAligned(d) {
const blk = new Date(d) const blk = getMondayOfISOWeek(d)
blk.setDate(d.getDate() - d.getDay())
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
return diff % interval === 0 return diff % interval === 0
} }
@ -236,12 +167,10 @@ export const useCalendarStore = defineStore('calendar', {
// Count occurrences BEFORE target (always include the base occurrence as first) // Count occurrences BEFORE target (always include the base occurrence as first)
const baseStart = new Date(base.startDate + 'T00:00:00') const baseStart = new Date(base.startDate + 'T00:00:00')
const baseBlockStart = new Date(baseStart) const baseBlockStart = getMondayOfISOWeek(baseStart)
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
function isAligned(d) { function isAligned(d) {
const block = new Date(d) const block = getMondayOfISOWeek(d)
block.setDate(d.getDate() - d.getDay())
const diff = Math.floor((block - baseBlockStart) / WEEK_MS) const diff = Math.floor((block - baseBlockStart) / WEEK_MS)
return diff % interval === 0 return diff % interval === 0
} }
@ -473,43 +402,6 @@ export const useCalendarStore = defineStore('calendar', {
return ev ? { ...ev } : null return ev ? { ...ev } : null
}, },
_removeEventFromAllDatesById(eventId) {
for (const [dateStr, set] of this.dates) {
if (set.has(eventId)) {
set.delete(eventId)
if (set.size === 0) this.dates.delete(dateStr)
}
}
},
_addEventToDateRangeWithId(eventId, baseData, startDate, endDate) {
// Update base data
this.events.set(eventId, {
...baseData,
id: eventId,
startDate,
endDate,
isSpanning: startDate < endDate,
})
this._indexEventDates(eventId)
},
_indexEventDates(eventId) {
const ev = this.events.get(eventId)
if (!ev) return
// remove old date references first
for (const [, set] of this.dates) set.delete(eventId)
const s = fromLocalString(ev.startDate)
const e = fromLocalString(ev.endDate)
const cur = new Date(s)
while (cur <= e) {
const dateStr = toLocalString(cur)
if (!this.dates.has(dateStr)) this.dates.set(dateStr, new Set())
this.dates.get(dateStr).add(ev.id)
cur.setDate(cur.getDate() + 1)
}
},
// expandRepeats removed: no physical occurrence expansion // expandRepeats removed: no physical occurrence expansion
// 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
@ -566,10 +458,13 @@ export const useCalendarStore = defineStore('calendar', {
snapshot.repeatWeekdays = rotated snapshot.repeatWeekdays = rotated
} }
} }
// Reindex // Update the event directly
this._removeEventFromAllDatesById(eventId) this.events.set(eventId, {
this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate) ...snapshot,
// no expansion startDate: snapshot.startDate,
endDate: snapshot.endDate,
isSpanning: snapshot.startDate < snapshot.endDate,
})
}, },
// Split a repeating series at a specific occurrence date and move that occurrence (and future) to new range // Split a repeating series at a specific occurrence date and move that occurrence (and future) to new range
@ -596,11 +491,9 @@ export const useCalendarStore = defineStore('calendar', {
const pattern = base.repeatWeekdays || [] const pattern = base.repeatWeekdays || []
if (!pattern.some(Boolean)) return if (!pattern.some(Boolean)) return
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
const blockStartBase = new Date(baseStart) const blockStartBase = getMondayOfISOWeek(baseStart)
blockStartBase.setDate(blockStartBase.getDate() - blockStartBase.getDay())
function isAligned(d) { function isAligned(d) {
const blk = new Date(d) const blk = getMondayOfISOWeek(d)
blk.setDate(d.getDate() - d.getDay())
const diff = Math.floor((blk - blockStartBase) / WEEK_MS) const diff = Math.floor((blk - blockStartBase) / WEEK_MS)
return diff % interval === 0 return diff % interval === 0
} }
@ -694,12 +587,6 @@ export const useCalendarStore = defineStore('calendar', {
return newId return newId
}, },
_reindexBaseEvent(eventId, snapshot, startDate, endDate) {
if (!snapshot) return
this._removeEventFromAllDatesById(eventId)
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
},
_terminateRepeatSeriesAtIndex(baseId, index) { _terminateRepeatSeriesAtIndex(baseId, index) {
const ev = this.events.get(baseId) const ev = this.events.get(baseId)
if (!ev || !ev.isRepeating) return if (!ev || !ev.isRepeating) return
@ -713,18 +600,13 @@ export const useCalendarStore = defineStore('calendar', {
// _findEventInAnyList removed (direct map access) // _findEventInAnyList removed (direct map access)
_addEventToDateRange(event) {
this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
this._indexEventDates(event.id)
},
// NOTE: legacy dynamic getEventById for synthetic occurrences removed. // NOTE: legacy dynamic getEventById for synthetic occurrences removed.
}, },
persist: { persist: {
key: 'calendar-store', key: 'calendar-store',
storage: localStorage, storage: localStorage,
// Persist new structures; keep legacy 'events' only for transitional restore if needed // Persist only events map, no dates indexing
paths: ['today', 'config', 'events', 'dates'], paths: ['today', 'config', 'events'],
serializer: { serializer: {
serialize(value) { serialize(value) {
return JSON.stringify(value, (_k, v) => { return JSON.stringify(value, (_k, v) => {

View File

@ -51,6 +51,18 @@ function fromLocalString(dateString) {
return new Date(year, month - 1, day) return new Date(year, month - 1, day)
} }
/**
* Get the Monday of the ISO week for a given date
* @param {Date} date - The date to get the Monday for
* @returns {Date} Date object representing the Monday of the ISO week
*/
function getMondayOfISOWeek(date) {
const d = new Date(date)
const dayOfWeek = (d.getDay() + 6) % 7 // Convert to Monday=0, Sunday=6
d.setDate(d.getDate() - dayOfWeek)
return d
}
/** /**
* Get the index of Monday for a given date (0-6, where Monday = 0) * Get the index of Monday for a given date (0-6, where Monday = 0)
* @param {Date} d - The date * @param {Date} d - The date
@ -59,6 +71,259 @@ function fromLocalString(dateString) {
const mondayIndex = (d) => (d.getDay() + 6) % 7 const mondayIndex = (d) => (d.getDay() + 6) % 7
/** /**
* Calculate the occurrence index for a repeating weekly event on a specific date
* @param {Object} event - The event object with repeat info
* @param {string} dateStr - The date string (YYYY-MM-DD) to check
* @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not valid
*/
function getWeeklyOccurrenceIndex(event, dateStr) {
if (!event.isRepeating || event.repeat !== 'weeks') return null
const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return null
const d = fromLocalString(dateStr)
const dow = d.getDay()
if (!pattern[dow]) return null
const baseStart = fromLocalString(event.startDate)
const interval = event.repeatInterval || 1
// Check if date resides in a week block that aligns with interval
const baseBlockStart = getMondayOfISOWeek(baseStart)
const currentBlockStart = getMondayOfISOWeek(d)
const WEEK_MS = 7 * 86400000
const blocksDiff = Math.floor((currentBlockStart - baseBlockStart) / WEEK_MS)
if (blocksDiff < 0 || blocksDiff % interval !== 0) return null
// For same week as base start, count from base start to target
if (currentBlockStart.getTime() === baseBlockStart.getTime()) {
let occurrenceIndex = 0
const cursor = new Date(baseStart)
while (cursor < d) {
if (pattern[cursor.getDay()]) occurrenceIndex++
cursor.setDate(cursor.getDate() + 1)
}
// Check against repeat count limit
if (event.repeatCount !== 'unlimited') {
const limit = parseInt(event.repeatCount, 10)
if (isNaN(limit) || occurrenceIndex >= limit) return null
}
return occurrenceIndex
}
// For different weeks, calculate based on complete intervals
const weekdaysPerInterval = pattern.filter(Boolean).length
const completeIntervals = blocksDiff / interval
let occurrenceIndex = completeIntervals * weekdaysPerInterval
// Add occurrences from the current week up to the target date
const cursor = new Date(currentBlockStart)
while (cursor < d) {
if (pattern[cursor.getDay()]) occurrenceIndex++
cursor.setDate(cursor.getDate() + 1)
}
// Check against repeat count limit
if (event.repeatCount !== 'unlimited') {
const limit = parseInt(event.repeatCount, 10)
if (isNaN(limit) || occurrenceIndex >= limit) return null
}
return occurrenceIndex
}
/**
* Calculate the occurrence index for a repeating monthly event on a specific date
* @param {Object} event - The event object with repeat info
* @param {string} dateStr - The date string (YYYY-MM-DD) to check
* @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not valid
*/
function getMonthlyOccurrenceIndex(event, dateStr) {
if (!event.isRepeating || event.repeat !== 'months') return null
const baseStart = fromLocalString(event.startDate)
const d = fromLocalString(dateStr)
const diffMonths =
(d.getFullYear() - baseStart.getFullYear()) * 12 + (d.getMonth() - baseStart.getMonth())
if (diffMonths < 0) return null
const interval = event.repeatInterval || 1
if (diffMonths % interval !== 0) return null
// Check day match (clamped for shorter months)
const baseDay = baseStart.getDate()
const daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
const effectiveDay = Math.min(baseDay, daysInMonth)
if (d.getDate() !== effectiveDay) return null
const occurrenceIndex = diffMonths / interval
// Check against repeat count limit
if (event.repeatCount !== 'unlimited') {
const limit = parseInt(event.repeatCount, 10)
if (isNaN(limit) || occurrenceIndex >= limit) return null
}
return occurrenceIndex
}
/**
* Check if a repeating event occurs on a specific date and return occurrence index
* @param {Object} event - The event object with repeat info
* @param {string} dateStr - The date string (YYYY-MM-DD) to check
* @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not occurring
*/
function getOccurrenceIndex(event, dateStr) {
if (!event || !event.isRepeating || event.repeat === 'none') return null
if (dateStr < event.startDate) return null
if (event.repeat === 'weeks') {
return getWeeklyOccurrenceIndex(event, dateStr)
} else if (event.repeat === 'months') {
return getMonthlyOccurrenceIndex(event, dateStr)
}
return null
}
/**
* Calculate the end date for a virtual occurrence of a repeating event
* @param {Object} event - The base event object
* @param {string} occurrenceStartDate - The start date of the occurrence (YYYY-MM-DD)
* @returns {string} The end date of the occurrence (YYYY-MM-DD)
*/
function getVirtualOccurrenceEndDate(event, occurrenceStartDate) {
const baseStart = fromLocalString(event.startDate)
const baseEnd = fromLocalString(event.endDate)
const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000)))
const occurrenceStart = fromLocalString(occurrenceStartDate)
const occurrenceEnd = new Date(occurrenceStart)
occurrenceEnd.setDate(occurrenceEnd.getDate() + spanDays)
return toLocalString(occurrenceEnd)
}
/**
* Check if a repeating event occurs on or spans through a specific date
* @param {Object} event - The event object with repeat info
* @param {string} dateStr - The date string (YYYY-MM-DD) to check
* @returns {boolean} True if the event occurs on or spans through the date
*/
function occursOnOrSpansDate(event, dateStr) {
if (!event || !event.isRepeating || event.repeat === 'none') return false
// Check if this is the base event spanning naturally
if (dateStr >= event.startDate && dateStr <= event.endDate) return true
// For virtual occurrences, we need to check if any occurrence spans through this date
const baseStart = fromLocalString(event.startDate)
const baseEnd = fromLocalString(event.endDate)
const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000)))
if (spanDays === 0) {
// Single day event - just check if it occurs on this date
return getOccurrenceIndex(event, dateStr) !== null
}
// Multi-day event - check if any occurrence's span includes this date
const targetDate = fromLocalString(dateStr)
if (event.repeat === 'weeks') {
const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return false
const interval = event.repeatInterval || 1
const baseBlockStart = getMondayOfISOWeek(baseStart)
const WEEK_MS = 7 * 86400000
// Check a reasonable range of weeks around the target date
for (
let weekOffset = -Math.ceil(spanDays / 7) - 1;
weekOffset <= Math.ceil(spanDays / 7) + 1;
weekOffset++
) {
const weekStart = new Date(baseBlockStart)
weekStart.setDate(weekStart.getDate() + weekOffset * 7)
// Check if this week aligns with the interval
const blocksDiff = Math.floor((weekStart - baseBlockStart) / WEEK_MS)
if (blocksDiff < 0 || blocksDiff % interval !== 0) continue
// Check each day in this week
for (let day = 0; day < 7; day++) {
const candidateStart = new Date(weekStart)
candidateStart.setDate(candidateStart.getDate() + day)
// Skip if before base start
if (candidateStart < baseStart) continue
// Check if this day matches the pattern
if (!pattern[candidateStart.getDay()]) continue
// Check repeat count limit
const occIndex = getWeeklyOccurrenceIndex(event, toLocalString(candidateStart))
if (occIndex === null) continue
// Calculate end date for this occurrence
const candidateEnd = new Date(candidateStart)
candidateEnd.setDate(candidateEnd.getDate() + spanDays)
// Check if target date falls within this occurrence's span
if (targetDate >= candidateStart && targetDate <= candidateEnd) {
return true
}
}
}
} else if (event.repeat === 'months') {
const interval = event.repeatInterval || 1
const baseDay = baseStart.getDate()
// Check a reasonable range of months around the target date
const targetYear = targetDate.getFullYear()
const targetMonth = targetDate.getMonth()
const baseYear = baseStart.getFullYear()
const baseMonth = baseStart.getMonth()
for (let monthOffset = -spanDays * 2; monthOffset <= spanDays * 2; monthOffset++) {
const candidateYear = baseYear + Math.floor((baseMonth + monthOffset) / 12)
const candidateMonth = (baseMonth + monthOffset + 12) % 12
// Check if this month aligns with the interval
const diffMonths = (candidateYear - baseYear) * 12 + (candidateMonth - baseMonth)
if (diffMonths < 0 || diffMonths % interval !== 0) continue
// Calculate the actual day (clamped for shorter months)
const daysInMonth = new Date(candidateYear, candidateMonth + 1, 0).getDate()
const effectiveDay = Math.min(baseDay, daysInMonth)
const candidateStart = new Date(candidateYear, candidateMonth, effectiveDay)
// Skip if before base start
if (candidateStart < baseStart) continue
// Check repeat count limit
const occIndex = getMonthlyOccurrenceIndex(event, toLocalString(candidateStart))
if (occIndex === null) continue
// Calculate end date for this occurrence
const candidateEnd = new Date(candidateStart)
candidateEnd.setDate(candidateEnd.getDate() + spanDays)
// Check if target date falls within this occurrence's span
if (targetDate >= candidateStart && targetDate <= candidateEnd) {
return true
}
}
}
return false
} /**
* Pad a number with leading zeros to make it 2 digits * Pad a number with leading zeros to make it 2 digits
* @param {number} n - Number to pad * @param {number} n - Number to pad
* @returns {string} Padded string * @returns {string} Padded string
@ -207,6 +472,12 @@ export {
isoWeekInfo, isoWeekInfo,
toLocalString, toLocalString,
fromLocalString, fromLocalString,
getMondayOfISOWeek,
getWeeklyOccurrenceIndex,
getMonthlyOccurrenceIndex,
getOccurrenceIndex,
getVirtualOccurrenceEndDate,
occursOnOrSpansDate,
mondayIndex, mondayIndex,
pad, pad,
daysInclusive, daysInclusive,