calendar/src/utils/date.js

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