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,
}