230 lines
6.9 KiB
JavaScript
230 lines
6.9 KiB
JavaScript
// 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,
|
|
}
|