394 lines
14 KiB
JavaScript
394 lines
14 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',
|
|
]
|
|
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,
|
|
}
|