From 8caaf33cb9cf6c030f2e489dbccd0265b857f82f Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 22 Aug 2025 22:08:35 -0600 Subject: [PATCH] A little better but still broken. I need easier tasks for a while. --- src/components/CalendarView.vue | 110 ++++++------- src/components/EventDialog.vue | 8 +- src/stores/CalendarStore.js | 172 ++++---------------- src/utils/date.js | 271 ++++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+), 216 deletions(-) diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index b9bfb8d..d8f0fe0 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -13,6 +13,9 @@ import { daysInclusive, addDaysStr, formatDateRange, + getMondayOfISOWeek, + getOccurrenceIndex, + getVirtualOccurrenceEndDate, } from '@/utils/date' import { toLocalString, fromLocalString } from '@/utils/date' @@ -153,25 +156,11 @@ function createWeek(virtualWeek) { for (let i = 0; i < 7; i++) { const dateStr = toLocalString(cur) const storedEvents = [] - const idSet = calendarStore.dates.get(dateStr) - if (idSet) { - // Support Set or Array; ignore unexpected shapes - if (idSet instanceof Set) { - idSet.forEach((id) => { - 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) - } + + // Find all non-repeating events that occur on this date + for (const ev of calendarStore.events.values()) { + if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) { + storedEvents.push(ev) } } // Build day events starting with stored (base/spanning) then virtual occurrences @@ -179,56 +168,45 @@ function createWeek(virtualWeek) { for (const base of repeatingBases) { // Skip if the base itself already on this date (already in storedEvents) if (dateStr >= base.startDate && dateStr <= base.endDate) continue - if (calendarStore.occursOnDate(base, dateStr)) { - // Determine sequential occurrence index: base event = 0, first repeat = 1, etc. - let recurrenceIndex = 0 - try { - if (base.repeat === 'weeks') { - const pattern = base.repeatWeekdays || [] - const interval = base.repeatInterval || 1 - const baseStart = new Date(base.startDate + 'T00:00:00') - const baseEnd = new Date(base.endDate + 'T00:00:00') - const target = new Date(dateStr + 'T00:00:00') - const WEEK_MS = 7 * 86400000 - const baseBlockStart = new Date(baseStart) - baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay()) - function isAligned(d) { - const blk = new Date(d) - blk.setDate(d.getDate() - d.getDay()) - const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) - return diff % interval === 0 + + // Check if any occurrence of this repeating event spans through this date + const baseStart = fromLocalString(base.startDate) + const baseEnd = fromLocalString(base.endDate) + const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) + const currentDate = fromLocalString(dateStr) + + let occurrenceFound = false + + // Check dates going backwards to find an occurrence that might span to this date + for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) { + const candidateStart = new Date(currentDate) + candidateStart.setDate(candidateStart.getDate() - offset) + const candidateStartStr = toLocalString(candidateStart) + + const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr) + if (occurrenceIndex !== null) { + // 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 - 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 + occurrenceFound = true } - } catch { - recurrenceIndex = 0 } - dayEvents.push({ - ...base, - id: base.id + '_v_' + dateStr, - startDate: dateStr, - endDate: dateStr, - _recurrenceIndex: recurrenceIndex, - _baseId: base.id, - }) } } const dow = cur.getDay() diff --git a/src/components/EventDialog.vue b/src/components/EventDialog.vue index d150ae5..4b1c299 100644 --- a/src/components/EventDialog.vue +++ b/src/components/EventDialog.vue @@ -3,7 +3,7 @@ import { useCalendarStore } from '@/stores/CalendarStore' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import WeekdaySelector from './WeekdaySelector.vue' import Numeric from './Numeric.vue' -import { addDaysStr } from '@/utils/date' +import { addDaysStr, getMondayOfISOWeek } from '@/utils/date' const props = defineProps({ 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 const interval = event.repeatInterval || 1 const WEEK_MS = 7 * 86400000 - const baseBlockStart = new Date(baseStart) - baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay()) + const baseBlockStart = getMondayOfISOWeek(baseStart) function isAligned(d) { - const blk = new Date(d) - blk.setDate(d.getDate() - d.getDay()) + const blk = getMondayOfISOWeek(d) const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) return diff % interval === 0 } diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js index 09f05e6..6598e1f 100644 --- a/src/stores/CalendarStore.js +++ b/src/stores/CalendarStore.js @@ -1,5 +1,15 @@ 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 MAX_YEAR = 2100 @@ -9,7 +19,6 @@ export const useCalendarStore = defineStore('calendar', { today: toLocalString(new Date()), now: new Date().toISOString(), // store as ISO string events: new Map(), // id -> event object (primary) - dates: new Map(), // dateStr -> Set of event ids weekend: getLocaleWeekendDays(), config: { select_days: 1000, @@ -28,74 +37,7 @@ export const useCalendarStore = defineStore('calendar', { actions: { // Determine if a repeating event occurs on a specific date (YYYY-MM-DD) without iterating all occurrences. occursOnDate(event, dateStr) { - if (!event || !event.isRepeating || event.repeat === 'none') return false - // 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 + return getOccurrenceIndex(event, dateStr) !== null }, updateCurrentDate() { 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._indexEventDates(event.id) return event.id }, @@ -170,14 +111,6 @@ export const useCalendarStore = defineStore('calendar', { }, 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) }, @@ -213,11 +146,9 @@ export const useCalendarStore = defineStore('calendar', { let found = 0 let safety = 0 const WEEK_MS = 7 * 86400000 - const baseBlockStart = new Date(baseStart) - baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay()) + const baseBlockStart = getMondayOfISOWeek(baseStart) function isAligned(d) { - const blk = new Date(d) - blk.setDate(d.getDate() - d.getDay()) + const blk = getMondayOfISOWeek(d) const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) return diff % interval === 0 } @@ -236,12 +167,10 @@ export const useCalendarStore = defineStore('calendar', { // Count occurrences BEFORE target (always include the base occurrence as first) const baseStart = new Date(base.startDate + 'T00:00:00') - const baseBlockStart = new Date(baseStart) - baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay()) + const baseBlockStart = getMondayOfISOWeek(baseStart) const WEEK_MS = 7 * 86400000 function isAligned(d) { - const block = new Date(d) - block.setDate(d.getDate() - d.getDay()) + const block = getMondayOfISOWeek(d) const diff = Math.floor((block - baseBlockStart) / WEEK_MS) return diff % interval === 0 } @@ -473,43 +402,6 @@ export const useCalendarStore = defineStore('calendar', { 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 // 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 } } - // Reindex - this._removeEventFromAllDatesById(eventId) - this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate) - // no expansion + // Update the event directly + this.events.set(eventId, { + ...snapshot, + 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 @@ -596,11 +491,9 @@ export const useCalendarStore = defineStore('calendar', { const pattern = base.repeatWeekdays || [] if (!pattern.some(Boolean)) return const WEEK_MS = 7 * 86400000 - const blockStartBase = new Date(baseStart) - blockStartBase.setDate(blockStartBase.getDate() - blockStartBase.getDay()) + const blockStartBase = getMondayOfISOWeek(baseStart) function isAligned(d) { - const blk = new Date(d) - blk.setDate(d.getDate() - d.getDay()) + const blk = getMondayOfISOWeek(d) const diff = Math.floor((blk - blockStartBase) / WEEK_MS) return diff % interval === 0 } @@ -694,12 +587,6 @@ export const useCalendarStore = defineStore('calendar', { return newId }, - _reindexBaseEvent(eventId, snapshot, startDate, endDate) { - if (!snapshot) return - this._removeEventFromAllDatesById(eventId) - this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate) - }, - _terminateRepeatSeriesAtIndex(baseId, index) { const ev = this.events.get(baseId) if (!ev || !ev.isRepeating) return @@ -713,18 +600,13 @@ export const useCalendarStore = defineStore('calendar', { // _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. }, persist: { key: 'calendar-store', storage: localStorage, - // Persist new structures; keep legacy 'events' only for transitional restore if needed - paths: ['today', 'config', 'events', 'dates'], + // Persist only events map, no dates indexing + paths: ['today', 'config', 'events'], serializer: { serialize(value) { return JSON.stringify(value, (_k, v) => { diff --git a/src/utils/date.js b/src/utils/date.js index d386e4f..ff83a2f 100644 --- a/src/utils/date.js +++ b/src/utils/date.js @@ -51,6 +51,18 @@ function fromLocalString(dateString) { 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) * @param {Date} d - The date @@ -59,6 +71,259 @@ function fromLocalString(dateString) { 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 * @param {number} n - Number to pad * @returns {string} Padded string @@ -207,6 +472,12 @@ export { isoWeekInfo, toLocalString, fromLocalString, + getMondayOfISOWeek, + getWeeklyOccurrenceIndex, + getMonthlyOccurrenceIndex, + getOccurrenceIndex, + getVirtualOccurrenceEndDate, + occursOnOrSpansDate, mondayIndex, pad, daysInclusive,