// date-utils.js — Restored & clean utilities (date-fns + timezone aware) import * as dateFns from 'date-fns' import { fromZonedTime, toZonedTime } from 'date-fns-tz' const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' // 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', ] const MIN_YEAR = 100 // less than 100 is interpreted as 19xx const MAX_YEAR = 9999 // 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, ).padStart(2, '0')}` const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone) return toZonedTime(utcDate, timeZone) } /** * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate). */ const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => makeTZDate(year, monthIndex, day, timeZone) /** * Construct a UTC-based date/time (wrapper for Date.UTC for consistency). */ const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) => new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms)) function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { return dateFns.format(toZonedTime(date, timeZone), 'yyyy-MM-dd') } function fromLocalString(dateString, timeZone = DEFAULT_TZ) { 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 } function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { const d = toZonedTime(date, timeZone) const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0 return dateFns.addDays(dateFns.startOfDay(d), -dow) } const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 // Count how many days in [startDate..endDate] match the boolean `pattern` array function countPatternDaysInInterval(startDate, endDate, patternArr) { const days = dateFns.eachDayOfInterval({ start: dateFns.startOfDay(startDate), end: dateFns.startOfDay(endDate), }) return days.reduce((c, d) => c + (patternArr[dateFns.getDay(d)] ? 1 : 0), 0) } // Recurrence: Weekly ------------------------------------------------------ function _getRecur(event) { return event?.recur ?? null } function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { const recur = _getRecur(event) if (!recur || recur.freq !== 'weeks') return null const pattern = recur.weekdays || [] if (!pattern.some(Boolean)) return null const target = fromLocalString(dateStr, timeZone) const baseStart = fromLocalString(event.startDate, timeZone) if (target < baseStart) return null const dow = dateFns.getDay(target) if (!pattern[dow]) return null // target not active const interval = recur.interval || 1 const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone) const currentBlockStart = getMondayOfISOWeek(target, timeZone) // Number of weeks between block starts (each block start is a Monday) const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart) if (weekDiff < 0 || weekDiff % interval !== 0) return null const baseDow = dateFns.getDay(baseStart) const baseCountsAsPattern = !!pattern[baseDow] // Same ISO week as base: count pattern days from baseStart up to target (inclusive) if (weekDiff === 0) { let n = countPatternDaysInInterval(baseStart, target, pattern) - 1 if (!baseCountsAsPattern) n += 1 const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) return n < 0 || n >= maxCount ? null : n } const baseWeekEnd = dateFns.addDays(baseBlockStart, 6) // Count pattern days in the first (possibly partial) week from baseStart..baseWeekEnd const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern) const alignedWeeksBetween = weekDiff / interval - 1 const fullPatternWeekCount = pattern.filter(Boolean).length const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0 // Count pattern days in the current (possibly partial) week from currentBlockStart..target const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern) let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1 if (!baseCountsAsPattern) n += 1 const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) return n >= maxCount ? null : n } // Recurrence: Monthly ----------------------------------------------------- function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { const recur = _getRecur(event) if (!recur || recur.freq !== 'months') return null const baseStart = fromLocalString(event.startDate, timeZone) const d = fromLocalString(dateStr, timeZone) const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart) if (diffMonths < 0) return null const interval = recur.interval || 1 if (diffMonths % interval !== 0) return null const baseDay = dateFns.getDate(baseStart) const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d)) if (dateFns.getDate(d) !== effectiveDay) return null const n = diffMonths / interval const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) return n >= maxCount ? null : n } function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { const recur = _getRecur(event) if (!recur) return null if (dateStr < event.startDate) return null if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone) if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone) return null } // Reverse lookup: given a recurrence index (0-based) return the occurrence start date string. // Returns null if the index is out of range or the event is not repeating. function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { const recur = _getRecur(event) if (!recur || recur.freq !== 'weeks') return null if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) if (occurrenceIndex >= maxCount) return null const pattern = recur.weekdays || [] if (!pattern.some(Boolean)) return null const interval = recur.interval || 1 const baseStart = fromLocalString(event.startDate, timeZone) if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone) const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone) const baseDow = dateFns.getDay(baseStart) const baseCountsAsPattern = !!pattern[baseDow] // Adjust index if base weekday is not part of the pattern (pattern occurrences shift by +1) let occ = occurrenceIndex if (!baseCountsAsPattern) occ -= 1 if (occ < 0) return null // Sorted list of active weekday indices const patternDays = [] for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d) // First (possibly partial) week: only pattern days >= baseDow and >= baseStart date const firstWeekDates = [] for (const d of patternDays) { if (d < baseDow) continue const date = dateFns.addDays(baseWeekMonday, d) if (date < baseStart) continue firstWeekDates.push(date) } const F = firstWeekDates.length if (occ < F) { return toLocalString(firstWeekDates[occ], timeZone) } const remaining = occ - F const P = patternDays.length if (P === 0) return null // Determine aligned week group (k >= 1) in which the remaining-th occurrence lies const k = Math.floor(remaining / P) + 1 // 1-based aligned week count after base week const indexInWeek = remaining % P const dow = patternDays[indexInWeek] const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow) return toLocalString(occurrenceDate, timeZone) } function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { const recur = _getRecur(event) if (!recur || recur.freq !== 'months') return null if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) if (occurrenceIndex >= maxCount) return null const interval = recur.interval || 1 const baseStart = fromLocalString(event.startDate, timeZone) const targetMonthOffset = occurrenceIndex * interval const monthDate = dateFns.addMonths(baseStart, targetMonthOffset) // Adjust day for shorter months (clamp like forward logic) const baseDay = dateFns.getDate(baseStart) const daysInTargetMonth = dateFns.getDaysInMonth(monthDate) const day = Math.min(baseDay, daysInTargetMonth) const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone) return toLocalString(actual, timeZone) } function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { const recur = _getRecur(event) if (!recur) return null if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone) if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone) return null } function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) { const spanDays = Math.max(0, (event.days || 1) - 1) const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone) return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone) } // Utility formatting & localization --------------------------------------- const pad = (n) => String(n).padStart(2, '0') function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { const a = fromLocalString(aStr, timeZone) const b = fromLocalString(bStr, timeZone) return ( Math.abs(dateFns.differenceInCalendarDays(dateFns.startOfDay(a), dateFns.startOfDay(b))) + 1 ) } function addDaysStr(str, n, timeZone = DEFAULT_TZ) { return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) } // Weekday name helpers now return Sunday-first ordering (index 0 = Sunday ... 6 = Saturday) function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { const sunday = makeTZDate(2025, 0, 5, timeZone) // a Sunday return Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( dateFns.addDays(sunday, i), ), ) } // Long (wide) localized weekday names, Sunday-first ordering function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) { const sunday = makeTZDate(2025, 0, 5, timeZone) return Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(undefined, { weekday: 'long', timeZone }).format( dateFns.addDays(sunday, i), ), ) } function getLocaleFirstDay() { const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1 return day % 7 } function getLocaleWeekendDays() { const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7]) return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7))) } function reorderByFirstDay(days, firstDay) { return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) } 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) } function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { 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}` } function lunarPhaseSymbol(date) { // 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 { t: 0.25, s: '🌓' }, // First Quarter { t: 0.5, s: '🌕' }, // Full { t: 0.75, s: '🌗' }, // Last Quarter ] 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 // wrap shortest arc if (delta * synodic <= thresholdDays) return p.s } return '' } // Exports ----------------------------------------------------------------- /** * Format date as short localized string (e.g., "Jan 15") */ function formatDateShort(date) { return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).replace(/, /, ' ') } /** * Format date as long localized string with optional year (e.g., "Mon Jan 15" or "Mon Jan 15, 2025") */ function formatDateLong(date, includeYear = false) { const opts = { weekday: 'short', month: 'short', day: 'numeric', ...(includeYear ? { year: 'numeric' } : {}), } return date.toLocaleDateString(undefined, opts) } /** * Format date as today string (e.g., "Monday\nJanuary 15") */ function formatTodayString(date) { const formatted = date .toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) .replace(/,? /, '\n') return formatted.charAt(0).toUpperCase() + formatted.slice(1) } export { // constants monthAbbr, MIN_YEAR, MAX_YEAR, DEFAULT_TZ, // core tz helpers makeTZDate, toLocalString, fromLocalString, // recurrence getMondayOfISOWeek, mondayIndex, getOccurrenceIndex, getOccurrenceDate, getVirtualOccurrenceEndDate, // formatting & localization pad, daysInclusive, addDaysStr, getLocalizedWeekdayNames, getLocalizedWeekdayNamesLong, getLocaleFirstDay, getLocaleWeekendDays, reorderByFirstDay, getLocalizedMonthName, formatDateRange, formatDateShort, formatDateLong, formatTodayString, lunarPhaseSymbol, // iso helpers re-export getISOWeek, getISOWeekYear, // constructors TZDate, UTCDate, }