diff --git a/src/components/CalendarGrid.vue b/src/components/CalendarGrid.vue index 563c009..abdfabb 100644 --- a/src/components/CalendarGrid.vue +++ b/src/components/CalendarGrid.vue @@ -25,7 +25,8 @@ import { getLocalizedWeekdayNames, getLocaleWeekendDays, getLocaleFirstDay, - isoWeekInfo, + getISOWeek, + getISOWeekYear, fromLocalString, toLocalString, mondayIndex, @@ -85,7 +86,7 @@ const updateVisibleWeeks = () => { const topDisplayIndex = Math.floor(scrollTop / rowHeight.value) const topVW = topDisplayIndex + minVirtualWeek.value const monday = getMondayForVirtualWeek(topVW) - const { year } = isoWeekInfo(monday) + const year = getISOWeekYear(monday) if (calendarStore.viewYear !== year) { calendarStore.setViewYear(year) } @@ -102,7 +103,7 @@ const updateVisibleWeeks = () => { const newVisibleWeeks = [] for (let vw = startVW; vw <= endVW; vw++) { - newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) }) + newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) }) } visibleWeeks.value = newVisibleWeeks } @@ -126,7 +127,7 @@ const handleWheel = (e) => { const navigateToYear = (targetYear, weekIndex) => { const monday = getMondayForVirtualWeek(weekIndex) - const { week } = isoWeekInfo(monday) + const week = getISOWeek(monday) const jan4 = new Date(targetYear, 0, 4) const jan4Monday = addDays(jan4, -mondayIndex(jan4)) const targetMonday = addDays(jan4Monday, (week - 1) * 7) diff --git a/src/components/CalendarHeader.vue b/src/components/CalendarHeader.vue index 0b20636..e9af1d6 100644 --- a/src/components/CalendarHeader.vue +++ b/src/components/CalendarHeader.vue @@ -1,7 +1,12 @@ diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js index c70ba8f..1bde058 100644 --- a/src/stores/CalendarStore.js +++ b/src/stores/CalendarStore.js @@ -21,7 +21,7 @@ const MAX_YEAR = 2100 export const useCalendarStore = defineStore('calendar', { state: () => ({ - today: toLocalString(new Date(), DEFAULT_TZ), + today: toLocalString(new Date(), DEFAULT_TZ), now: new Date().toISOString(), events: new Map(), weekend: getLocaleWeekendDays(), @@ -82,7 +82,7 @@ export const useCalendarStore = defineStore('calendar', { updateCurrentDate() { const d = new Date() this.now = d.toISOString() - const today = toLocalString(d, DEFAULT_TZ) + const today = toLocalString(d, DEFAULT_TZ) if (this.today !== today) { this.today = today } @@ -344,7 +344,7 @@ export const useCalendarStore = defineStore('calendar', { if (!targetDate) return // Count occurrences BEFORE target (always include the base occurrence as first) - const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) + const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) const baseBlockStart = getMondayOfISOWeek(baseStart) const WEEK_MS = 7 * 86400000 function isAligned(d) { @@ -355,7 +355,7 @@ export const useCalendarStore = defineStore('calendar', { // 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 + probe = addDays(probe, 1) // start counting AFTER base let safety2 = 0 while (probe < targetDate && safety2 < 50000) { if (pattern[probe.getDay()] && isAligned(probe)) countBefore++ @@ -377,7 +377,7 @@ export const useCalendarStore = defineStore('calendar', { } // Continuation starts at NEXT valid occurrence (matching weekday & aligned block) - let continuationStart = new Date(targetDate) + let continuationStart = new Date(targetDate) let searchSafety = 0 let foundNext = false while (searchSafety < 50000) { @@ -463,11 +463,11 @@ export const useCalendarStore = defineStore('calendar', { } } // 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) + 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, @@ -497,9 +497,9 @@ export const useCalendarStore = defineStore('calendar', { 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)) + 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 @@ -519,7 +519,7 @@ export const useCalendarStore = defineStore('calendar', { return diff % interval === 0 } // search forward for next valid weekday respecting interval alignment - let probe = new Date(oldStart) + let probe = new Date(oldStart) let safety = 0 while (safety < 5000) { probe = addDays(probe, 1) @@ -565,9 +565,9 @@ export const useCalendarStore = defineStore('calendar', { } } - const newEndDate = addDays(newStartDate, spanDays) - base.startDate = toLocalString(newStartDate, DEFAULT_TZ) - base.endDate = toLocalString(newEndDate, DEFAULT_TZ) + 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) @@ -579,13 +579,13 @@ export const useCalendarStore = defineStore('calendar', { const snapshot = this.events.get(eventId) if (!snapshot) return // Calculate current duration in days (inclusive) - const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ) - const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ) - const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart)) + const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ) + const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ) + const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart)) - const newStart = fromLocalString(newStartStr, DEFAULT_TZ) - const newEnd = fromLocalString(newEndStr, DEFAULT_TZ) - const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart)) + const newStart = fromLocalString(newStartStr, DEFAULT_TZ) + const newEnd = fromLocalString(newEndStr, DEFAULT_TZ) + const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart)) let finalDurationDays = prevDurationDays if (mode === 'resize-left' || mode === 'resize-right') { @@ -593,7 +593,10 @@ export const useCalendarStore = defineStore('calendar', { } snapshot.startDate = newStartStr - snapshot.endDate = toLocalString(addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays), DEFAULT_TZ) + snapshot.endDate = toLocalString( + addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays), + DEFAULT_TZ, + ) // Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift if ( mode === 'move' && @@ -601,8 +604,8 @@ export const useCalendarStore = defineStore('calendar', { snapshot.repeat === 'weeks' && Array.isArray(snapshot.repeatWeekdays) ) { - const oldDow = prevStart.getDay() - const newDow = newStart.getDay() + const oldDow = prevStart.getDay() + const newDow = newStart.getDay() const shift = newDow - oldDow if (shift !== 0) { const rotated = [false, false, false, false, false, false, false] @@ -630,7 +633,7 @@ export const useCalendarStore = defineStore('calendar', { const base = this.events.get(baseId) if (!base || !base.isRepeating) return const originalCountRaw = base.repeatCount - // spanDays not needed for splitting logic here post-refactor + // spanDays not needed for splitting logic here post-refactor const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) if (occurrenceDate <= baseStart) { @@ -650,7 +653,7 @@ export const useCalendarStore = defineStore('calendar', { const diff = Math.floor((blk - blockStartBase) / WEEK_MS) return diff % interval === 0 } - let cursor = new Date(baseStart) + let cursor = new Date(baseStart) while (cursor < occurrenceDate) { if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++ cursor = addDays(cursor, 1) @@ -683,7 +686,7 @@ export const useCalendarStore = defineStore('calendar', { if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) { // Rotate pattern so that the moved occurrence weekday stays active relative to new anchor const origWeekday = occurrenceDate.getDay() - const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay() + const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay() const shift = newWeekday - origWeekday if (shift !== 0) { const rotated = [false, false, false, false, false, false, false] diff --git a/src/utils/date.js b/src/utils/date.js index d1d44cd..0ec09dc 100644 --- a/src/utils/date.js +++ b/src/utils/date.js @@ -1,524 +1,244 @@ -// date-utils.js — Date handling utilities for the calendar (refactored to use date-fns/date-fns-tz) -import { - addDays, - differenceInCalendarDays, - differenceInCalendarMonths, - format, - getDate, - getDay, - getDaysInMonth, - getMonth, - getYear, - isAfter, - isBefore, - isEqual, - parseISO, - startOfDay, -} from 'date-fns' +// date-utils.js — Restored & clean utilities (date-fns + timezone aware) +import * as dateFns from 'date-fns' import { fromZonedTime, toZonedTime } from 'date-fns-tz' -// We expose a simple alias TZDate for clarity. date-fns by default works with native Date objects. -// Consumers can pass an optional timeZone; if omitted, local time zone is assumed. - const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' -// Helper to create a zoned date (keeps wall-clock components in provided TZ) +// Re-exported iso helpers (keep the same exported names used elsewhere) +const getISOWeek = dateFns.getISOWeek +const getISOWeekYear = dateFns.getISOWeekYear + +// Constants +const monthAbbr = [ + 'jan', + 'feb', + 'mar', + 'apr', + 'may', + 'jun', + 'jul', + 'aug', + 'sep', + 'oct', + 'nov', + 'dec', +] + +// Core helpers ------------------------------------------------------------ +/** + * Construct a date at local midnight in the specified IANA timezone. + * Returns a native Date whose wall-clock components in that zone are (Y, M, D 00:00:00). + */ function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String( - day + day, ).padStart(2, '0')}` - // Interpret as start of day in target zone const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone) return toZonedTime(utcDate, timeZone) } -const monthAbbr = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] -const DAY_MS = 86400000 -const WEEK_MS = 7 * DAY_MS +/** + * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate). + */ +const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => + makeTZDate(year, monthIndex, day, timeZone) /** - * Get ISO week information for a given date - * @param {Date} date - The date to get week info for - * @returns {Object} Object containing week number and year + * Construct a UTC-based date/time (wrapper for Date.UTC for consistency). */ -const isoWeekInfo = (date) => { - // ISO week: Thursday algorithm - const d = new Date(Date.UTC(getYear(date), getMonth(date), getDate(date))) - const day = d.getUTCDay() || 7 - d.setUTCDate(d.getUTCDate() + 4 - day) - const year = d.getUTCFullYear() - const yearStart = new Date(Date.UTC(year, 0, 1)) - const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1 - return { week: Math.ceil(diffDays / 7), year } -} +const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) => + new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms)) -/** - * Convert a Date object to a local date string (YYYY-MM-DD format) - * @param {Date} date - The date to convert (defaults to new Date()) - * @returns {string} Date string in YYYY-MM-DD format - */ function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { - return format(toZonedTime(date, timeZone), 'yyyy-MM-dd') + return dateFns.format(toZonedTime(date, timeZone), 'yyyy-MM-dd') } -/** - * Convert a local date string (YYYY-MM-DD) to a Date object - * @param {string} dateString - Date string in YYYY-MM-DD format - * @returns {Date} Date object - */ function fromLocalString(dateString, timeZone = DEFAULT_TZ) { - const parsed = parseISO(dateString) + if (!dateString) return makeTZDate(1970, 0, 1, timeZone) + const parsed = dateFns.parseISO(dateString) const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone) return toZonedTime(utcDate, timeZone) || parsed } -/** - * 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, timeZone = DEFAULT_TZ) { - const d = startOfDay(toZonedTime(date, timeZone)) - const dayOfWeek = (getDay(d) + 6) % 7 - return addDays(d, -dayOfWeek) + const d = toZonedTime(date, timeZone) + const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0 + return dateFns.addDays(dateFns.startOfDay(d), -dow) } -/** - * Get the index of Monday for a given date (0-6, where Monday = 0) - * @param {Date} d - The date - * @returns {number} Monday index (0-6) - */ -const mondayIndex = (d) => (getDay(d) + 6) % 7 +const mondayIndex = (d) => (dateFns.getDay(d) + 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 - */ +// 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 getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { - if (!event.isRepeating || event.repeat !== 'weeks') return null - + if (!event?.isRepeating || event.repeat !== 'weeks') return null const pattern = event.repeatWeekdays || [] if (!pattern.some(Boolean)) return null - const d = fromLocalString(dateStr, timeZone) - const dow = getDay(d) - if (!pattern[dow]) 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 = event.repeatInterval || 1 - - // Check if date resides in a week block that aligns with interval const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone) - const currentBlockStart = getMondayOfISOWeek(d, timeZone) - const WEEK_MS = 7 * 86400000 - const blocksDiff = Math.floor((currentBlockStart - baseBlockStart) / WEEK_MS) + 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 - if (blocksDiff < 0 || blocksDiff % interval !== 0) return null - - // For same week as base start, count from base start to target - if (isEqual(currentBlockStart, baseBlockStart)) { - // Special handling for the first week - only count occurrences on or after base date - if (d.getTime() === baseStart.getTime()) { - return 0 // Base occurrence is always index 0 - } - - if (d < baseStart) { - return null // Dates before base start in same week are not valid occurrences - } - - let occurrenceIndex = 0 - let cursor = new Date(baseStart) - - // Count the base occurrence first - if (pattern[getDay(cursor)]) occurrenceIndex++ - - // Move to the next day and count until we reach the target - cursor = addDays(cursor, 1) - while (cursor <= d) { - if (pattern[getDay(cursor)]) occurrenceIndex++ - cursor = addDays(cursor, 1) - } - - // Subtract 1 because we want the index, not the count - occurrenceIndex-- - - // Check against repeat count limit - if (event.repeatCount !== 'unlimited') { - const limit = parseInt(event.repeatCount, 10) - if (isNaN(limit) || occurrenceIndex >= limit) return null - } - - return occurrenceIndex + // Same ISO week as base: count pattern days from baseStart up to target (inclusive) + if (weekDiff === 0) { + const n = countPatternDaysInInterval(baseStart, target, pattern) - 1 + return n < 0 || n >= event.repeatCount ? null : n } - // For different weeks, calculate based on complete intervals - // Calculate how many pattern days actually occur in the first week (from base start onward) - let firstWeekPatternDays = 0 - let firstWeekCursor = new Date(baseStart) - const firstWeekEnd = addDays(new Date(baseBlockStart), 6) // End of first week (Sunday) - - while (firstWeekCursor <= firstWeekEnd) { - if (pattern[getDay(firstWeekCursor)]) firstWeekPatternDays++ - firstWeekCursor = addDays(firstWeekCursor, 1) - } - - // For subsequent complete intervals, use the full pattern count - const fullWeekdaysPerInterval = pattern.filter(Boolean).length - const completeIntervals = blocksDiff / interval - - // First interval uses actual first week count, remaining intervals use full count - let occurrenceIndex = firstWeekPatternDays + (completeIntervals - 1) * fullWeekdaysPerInterval - - // Add occurrences from the current week up to the target date - cursor = new Date(currentBlockStart) - while (cursor < d) { - if (pattern[getDay(cursor)]) occurrenceIndex++ - cursor = addDays(cursor, 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 + 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) + const n = firstWeekCount + middleWeeksCount + currentWeekCount - 1 + return n >= event.repeatCount ? null : n } -/** - * 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 - */ +// Recurrence: Monthly ----------------------------------------------------- function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { - if (!event.isRepeating || event.repeat !== 'months') return null - + if (!event?.isRepeating || event.repeat !== 'months') return null const baseStart = fromLocalString(event.startDate, timeZone) const d = fromLocalString(dateStr, timeZone) - const diffMonths = differenceInCalendarMonths(d, baseStart) - + const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart) 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 = getDate(baseStart) - const daysInMonth = getDaysInMonth(d) - const effectiveDay = Math.min(baseDay, daysInMonth) - if (getDate(d) !== 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 + const baseDay = dateFns.getDate(baseStart) + const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d)) + if (dateFns.getDate(d) !== effectiveDay) return null + const n = diffMonths / interval + return n >= event.repeatCount ? null : n } -/** - * 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, timeZone = DEFAULT_TZ) { - if (!event || !event.isRepeating || event.repeat === 'none') return null + if (!event?.isRepeating || event.repeat === 'none') return null if (dateStr < event.startDate) return null - - if (event.repeat === 'weeks') { - return getWeeklyOccurrenceIndex(event, dateStr, timeZone) - } else if (event.repeat === 'months') { - return getMonthlyOccurrenceIndex(event, dateStr, timeZone) - } - + if (event.repeat === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone) + if (event.repeat === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone) 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, timeZone = DEFAULT_TZ) { const baseStart = fromLocalString(event.startDate, timeZone) const baseEnd = fromLocalString(event.endDate, timeZone) - const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart)) + const spanDays = Math.max(0, dateFns.differenceInCalendarDays(baseEnd, baseStart)) const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone) - const occurrenceEnd = addDays(occurrenceStart, spanDays) - return toLocalString(occurrenceEnd, timeZone) + return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone) } -/** - * 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, timeZone = DEFAULT_TZ) { - 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, timeZone) - const baseEnd = fromLocalString(event.endDate, timeZone) - const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart)) - - 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, timeZone) - - if (event.repeat === 'weeks') { - const pattern = event.repeatWeekdays || [] - if (!pattern.some(Boolean)) return false - - const interval = event.repeatInterval || 1 - const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone) - 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 = addDays(baseBlockStart, 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 = addDays(weekStart, day) - - // Skip if before base start - if (isBefore(candidateStart, baseStart)) continue - - // Check if this day matches the pattern - if (!pattern[getDay(candidateStart)]) continue - - // Check repeat count limit - const occIndex = getWeeklyOccurrenceIndex(event, toLocalString(candidateStart, timeZone), timeZone) - if (occIndex === null) continue - - // Calculate end date for this occurrence - const candidateEnd = addDays(candidateStart, spanDays) - - // Check if target date falls within this occurrence's span - if (!isBefore(targetDate, candidateStart) && !isAfter(targetDate, candidateEnd)) { - return true - } - } - } - } else if (event.repeat === 'months') { - const interval = event.repeatInterval || 1 - const baseDay = getDate(baseStart) - - // Check a reasonable range of months around the target date - // targetYear & targetMonth not needed in refactored logic - const baseYear = getYear(baseStart) - const baseMonth = getMonth(baseStart) - - 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 = getDaysInMonth(new Date(candidateYear, candidateMonth, 1)) - const effectiveDay = Math.min(baseDay, daysInMonth) - const candidateStart = makeTZDate(candidateYear, candidateMonth, effectiveDay) - - // Skip if before base start - if (isBefore(candidateStart, baseStart)) continue - - // Check repeat count limit - const occIndex = getMonthlyOccurrenceIndex(event, toLocalString(candidateStart, timeZone), timeZone) - if (occIndex === null) continue - - // Calculate end date for this occurrence - const candidateEnd = addDays(candidateStart, spanDays) - - // Check if target date falls within this occurrence's span - if (!isBefore(targetDate, candidateStart) && !isAfter(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 - */ +// Utility formatting & localization --------------------------------------- const pad = (n) => String(n).padStart(2, '0') -/** - * Calculate number of days between two date strings (inclusive) - * @param {string} aStr - First date string (YYYY-MM-DD) - * @param {string} bStr - Second date string (YYYY-MM-DD) - * @returns {number} Number of days inclusive - */ function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { const a = fromLocalString(aStr, timeZone) const b = fromLocalString(bStr, timeZone) - return Math.abs(differenceInCalendarDays(startOfDay(a), startOfDay(b))) + 1 + return ( + Math.abs(dateFns.differenceInCalendarDays(dateFns.startOfDay(a), dateFns.startOfDay(b))) + 1 + ) } -/** - * Add days to a date string - * @param {string} str - Date string in YYYY-MM-DD format - * @param {number} n - Number of days to add (can be negative) - * @returns {string} New date string - */ function addDaysStr(str, n, timeZone = DEFAULT_TZ) { - const d = fromLocalString(str, timeZone) - return toLocalString(addDays(d, n), timeZone) + return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) } -/** - * Get localized weekday names starting from Monday - * @returns {Array} Array of localized weekday names - */ function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { - const res = [] - const base = makeTZDate(2025, 0, 6, timeZone) // Monday - for (let i = 0; i < 7; i++) { - const d = addDays(base, i) - res.push( - new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format(d) - ) - } - return res + const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday + return Array.from({ length: 7 }, (_, i) => + new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( + dateFns.addDays(monday, i), + ), + ) } -/** - * Get the locale's first day of the week (0=Sunday, 1=Monday, etc.) - * @returns {number} First day of the week (0-6) - */ function getLocaleFirstDay() { - try { - return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 - } catch { - return 1 // Default to Monday if locale info not available - } + return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 } -/** - * Get the locale's weekend days as an array of booleans (Sunday=index 0) - * @returns {Array} Array where true indicates a weekend day - */ function getLocaleWeekendDays() { - try { - const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend - const dayidx = new Set(localeWeekend) - return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7)) - } catch { - return [true, false, false, false, false, false, true] // Default to Saturday/Sunday weekend - } + const wk = new Intl.Locale(navigator.language).weekInfo.weekend || [6, 7] + const set = new Set(wk.map((d) => d % 7)) + return Array.from({ length: 7 }, (_, i) => set.has(i)) } -/** - * Reorder a 7-element array based on the first day of the week - * @param {Array} days - Array of 7 elements (Sunday=index 0) - * @param {number} firstDay - First day of the week (0=Sunday, 1=Monday, etc.) - * @returns {Array} Reordered array - */ function reorderByFirstDay(days, firstDay) { return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) } -/** - * Get localized month name - * @param {number} idx - Month index (0-11) - * @param {boolean} short - Whether to return short name - * @returns {string} Localized month name - */ function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) { const d = makeTZDate(2025, idx, 1, timeZone) return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d) } -/** - * Format a date range for display - * @param {Date} startDate - Start date - * @param {Date} endDate - End date - * @returns {string} Formatted date range string - */ function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { - if (toLocalString(startDate, timeZone) === toLocalString(endDate, timeZone)) - return toLocalString(startDate, timeZone) - const startISO = toLocalString(startDate, timeZone) - const endISO = toLocalString(endDate, timeZone) - const [sy, sm] = startISO.split('-') - const [ey, em, ed] = endISO.split('-') - if (sy === ey && sm === em) return `${startISO}/${ed}` - if (sy === ey) return `${startISO}/${em}-${ed}` - return `${startISO}/${endISO}` + const a = toLocalString(startDate, timeZone) + const b = toLocalString(endDate, timeZone) + if (a === b) return a + const [ay, am] = a.split('-') + const [by, bm, bd] = b.split('-') + if (ay === by && am === bm) return `${a}/${bd}` + if (ay === by) return `${a}/${bm}-${bd}` + return `${a}/${b}` } -/** - * Compute lunar phase symbol for the four main phases on a given date. - * Returns one of: 🌑 (new), 🌓 (first quarter), 🌕 (full), 🌗 (last quarter), or '' otherwise. - * Uses an approximate algorithm with a fixed epoch. - */ function lunarPhaseSymbol(date) { - // Reference new moon: 2000-01-06 18:14 UTC (J2000 era), often used in approximations - const ref = Date.UTC(2000, 0, 6, 18, 14, 0) - const synodic = 29.530588853 // days - // Use UTC noon of given date to reduce timezone edge effects - const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0) - const daysSince = (dUTC - ref) / DAY_MS - const phase = (((daysSince / synodic) % 1) + 1) % 1 + // Reference new moon (J2000 era) used for approximate phase calculations + const ref = UTCDate(2000, 0, 6, 18, 14, 0) + const obs = new Date(date) + obs.setHours(12, 0, 0, 0) + const synodic = 29.530588853 // mean synodic month length in days + const daysSince = dateFns.differenceInMinutes(obs, ref) / 60 / 24 + const phase = (((daysSince / synodic) % 1) + 1) % 1 // normalize to [0,1) const phases = [ - { t: 0.0, s: '🌑' }, // New Moon + { t: 0.0, s: '🌑' }, // New { t: 0.25, s: '🌓' }, // First Quarter - { t: 0.5, s: '🌕' }, // Full Moon + { t: 0.5, s: '🌕' }, // Full { t: 0.75, s: '🌗' }, // Last Quarter ] - // threshold in days from exact phase to still count for this date - const thresholdDays = 0.5 // ±12 hours + const thresholdDays = 0.5 // within ~12h of exact phase for (const p of phases) { let delta = Math.abs(phase - p.t) - if (delta > 0.5) delta = 1 - delta + if (delta > 0.5) delta = 1 - delta // wrap shortest arc if (delta * synodic <= thresholdDays) return p.s } return '' } -// Export all functions and constants +// Exports ----------------------------------------------------------------- export { + // constants monthAbbr, - DAY_MS, - WEEK_MS, - isoWeekInfo, + DEFAULT_TZ, + // core tz helpers + makeTZDate, toLocalString, fromLocalString, + // recurrence getMondayOfISOWeek, - getWeeklyOccurrenceIndex, - getMonthlyOccurrenceIndex, + mondayIndex, getOccurrenceIndex, getVirtualOccurrenceEndDate, - occursOnOrSpansDate, - mondayIndex, + // formatting & localization pad, daysInclusive, addDaysStr, @@ -529,6 +249,10 @@ export { getLocalizedMonthName, formatDateRange, lunarPhaseSymbol, - makeTZDate, - DEFAULT_TZ, + // iso helpers re-export + getISOWeek, + getISOWeekYear, + // constructors + TZDate, + UTCDate, }