// 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', ] // Browser safe range const MIN_YEAR = 100 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 // (Recurrence utilities moved to events.js) // 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, // 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, }