Major new version (#2)
Release Notes Architecture - Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components. - Dialog system introduced (`BaseDialog`, `SettingsDialog`). State & Data - Store redesigned: Map-based events + recurrence map; mutation counters. - Local persistence + undo/redo history (custom plugins). Date & Holidays - Migrated all date logic to `date-fns` (+ tz). - Added national holiday support (toggle + loading utilities). Recurrence & Events - Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats. - Reliable splitting/moving/resizing/deletion of repeating and multi‑day events. Interaction & UX - Double‑tap to create events; improved drag (multi‑day + position retention). - Scroll & inertial/momentum navigation; year change via numeric scroller. - Movable event dialog; live settings application. Performance - Progressive / virtual week rendering, reduced off‑screen buffer. - Targeted repaint strategy; minimized full re-renders. Plugins Added - History, undo normalization, persistence, scroll manager, virtual weeks. Styling & Layout - Responsive + compact layout refinements; header restructured. - Simplified visual elements (removed dots/overflow text); holiday styling adjustments. Reliability / Fixes - Numerous recurrence, deletion, orientation/rotation, and event indexing corrections. - Cross-browser fallback (Firefox week info). Dependencies Added - date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate. Net Change - 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
This commit is contained in:
+305
-145
@@ -1,4 +1,14 @@
|
||||
// date-utils.js — Date handling utilities for the calendar
|
||||
// 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',
|
||||
@@ -13,201 +23,342 @@ const monthAbbr = [
|
||||
'nov',
|
||||
'dec',
|
||||
]
|
||||
const DAY_MS = 86400000
|
||||
const WEEK_MS = 7 * DAY_MS
|
||||
const MIN_YEAR = 100 // less than 100 is interpreted as 19xx
|
||||
const MAX_YEAR = 9999
|
||||
|
||||
// Core helpers ------------------------------------------------------------
|
||||
/**
|
||||
* Get ISO week information for a given date
|
||||
* @param {Date} date - The date to get week info for
|
||||
* @returns {Object} Object containing week number and year
|
||||
* 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).
|
||||
*/
|
||||
const isoWeekInfo = (date) => {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
|
||||
const day = d.getUTCDay() || 7
|
||||
d.setUTCDate(d.getUTCDate() + 4 - day)
|
||||
const year = d.getUTCFullYear()
|
||||
const yearStart = new Date(Date.UTC(year, 0, 1))
|
||||
const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1
|
||||
return { week: Math.ceil(diffDays / 7), year }
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Date object to a local date string (YYYY-MM-DD format)
|
||||
* @param {Date} date - The date to convert (defaults to new Date())
|
||||
* @returns {string} Date string in YYYY-MM-DD format
|
||||
* Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate).
|
||||
*/
|
||||
function toLocalString(date = new Date()) {
|
||||
const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a local date string (YYYY-MM-DD) to a Date object
|
||||
* @param {string} dateString - Date string in YYYY-MM-DD format
|
||||
* @returns {Date} Date object
|
||||
*/
|
||||
function fromLocalString(dateString) {
|
||||
const [year, month, day] = dateString.split('-').map(Number)
|
||||
return new Date(year, month - 1, day)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the index of Monday for a given date (0-6, where Monday = 0)
|
||||
* @param {Date} d - The date
|
||||
* @returns {number} Monday index (0-6)
|
||||
*/
|
||||
const mondayIndex = (d) => (d.getDay() + 6) % 7
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a number with leading zeros to make it 2 digits
|
||||
* @param {number} n - Number to pad
|
||||
* @returns {string} Padded string
|
||||
*/
|
||||
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')
|
||||
|
||||
/**
|
||||
* Calculate number of days between two date strings (inclusive)
|
||||
* @param {string} aStr - First date string (YYYY-MM-DD)
|
||||
* @param {string} bStr - Second date string (YYYY-MM-DD)
|
||||
* @returns {number} Number of days inclusive
|
||||
*/
|
||||
function daysInclusive(aStr, bStr) {
|
||||
const a = fromLocalString(aStr)
|
||||
const b = fromLocalString(bStr)
|
||||
const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
|
||||
const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
|
||||
return Math.floor(Math.abs(B - A) / DAY_MS) + 1
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add days to a date string
|
||||
* @param {string} str - Date string in YYYY-MM-DD format
|
||||
* @param {number} n - Number of days to add (can be negative)
|
||||
* @returns {string} New date string
|
||||
*/
|
||||
function addDaysStr(str, n) {
|
||||
const d = fromLocalString(str)
|
||||
d.setDate(d.getDate() + n)
|
||||
return toLocalString(d)
|
||||
function addDaysStr(str, n, timeZone = DEFAULT_TZ) {
|
||||
return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized weekday names starting from Monday
|
||||
* @returns {Array<string>} Array of localized weekday names
|
||||
*/
|
||||
function getLocalizedWeekdayNames() {
|
||||
const res = []
|
||||
const base = new Date(2025, 0, 6) // A Monday
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(base)
|
||||
d.setDate(base.getDate() + i)
|
||||
res.push(d.toLocaleDateString(undefined, { weekday: 'short' }))
|
||||
}
|
||||
return res
|
||||
function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
|
||||
const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format(
|
||||
dateFns.addDays(monday, i),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locale's first day of the week (0=Sunday, 1=Monday, etc.)
|
||||
* @returns {number} First day of the week (0-6)
|
||||
*/
|
||||
function getLocaleFirstDay() {
|
||||
try {
|
||||
return new Intl.Locale(navigator.language).weekInfo.firstDay % 7
|
||||
} catch {
|
||||
return 1 // Default to Monday if locale info not available
|
||||
}
|
||||
const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1
|
||||
return day % 7
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the locale's weekend days as an array of booleans (Sunday=index 0)
|
||||
* @returns {Array<boolean>} Array where true indicates a weekend day
|
||||
*/
|
||||
function getLocaleWeekendDays() {
|
||||
try {
|
||||
const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend
|
||||
const dayidx = new Set(localeWeekend)
|
||||
return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7))
|
||||
} catch {
|
||||
return [true, false, false, false, false, false, true] // Default to Saturday/Sunday weekend
|
||||
}
|
||||
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)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder a 7-element array based on the first day of the week
|
||||
* @param {Array} days - Array of 7 elements (Sunday=index 0)
|
||||
* @param {number} firstDay - First day of the week (0=Sunday, 1=Monday, etc.)
|
||||
* @returns {Array} Reordered array
|
||||
*/
|
||||
function reorderByFirstDay(days, firstDay) {
|
||||
return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7])
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized month name
|
||||
* @param {number} idx - Month index (0-11)
|
||||
* @param {boolean} short - Whether to return short name
|
||||
* @returns {string} Localized month name
|
||||
*/
|
||||
function getLocalizedMonthName(idx, short = false) {
|
||||
const d = new Date(2025, idx, 1)
|
||||
return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' })
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date range for display
|
||||
* @param {Date} startDate - Start date
|
||||
* @param {Date} endDate - End date
|
||||
* @returns {string} Formatted date range string
|
||||
*/
|
||||
function formatDateRange(startDate, endDate) {
|
||||
if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate)
|
||||
const startISO = toLocalString(startDate)
|
||||
const endISO = toLocalString(endDate)
|
||||
const [sy, sm] = startISO.split('-')
|
||||
const [ey, em, ed] = endISO.split('-')
|
||||
if (sy === ey && sm === em) return `${startISO}/${ed}`
|
||||
if (sy === ey) return `${startISO}/${em}-${ed}`
|
||||
return `${startISO}/${endISO}`
|
||||
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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute lunar phase symbol for the four main phases on a given date.
|
||||
* Returns one of: 🌑 (new), 🌓 (first quarter), 🌕 (full), 🌗 (last quarter), or '' otherwise.
|
||||
* Uses an approximate algorithm with a fixed epoch.
|
||||
*/
|
||||
function lunarPhaseSymbol(date) {
|
||||
// Reference new moon: 2000-01-06 18:14 UTC (J2000 era), often used in approximations
|
||||
const ref = Date.UTC(2000, 0, 6, 18, 14, 0)
|
||||
const synodic = 29.530588853 // days
|
||||
// Use UTC noon of given date to reduce timezone edge effects
|
||||
const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0)
|
||||
const daysSince = (dUTC - ref) / DAY_MS
|
||||
const phase = (((daysSince / synodic) % 1) + 1) % 1
|
||||
// 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 Moon
|
||||
{ t: 0.0, s: '🌑' }, // New
|
||||
{ t: 0.25, s: '🌓' }, // First Quarter
|
||||
{ t: 0.5, s: '🌕' }, // Full Moon
|
||||
{ t: 0.5, s: '🌕' }, // Full
|
||||
{ t: 0.75, s: '🌗' }, // Last Quarter
|
||||
]
|
||||
// threshold in days from exact phase to still count for this date
|
||||
const thresholdDays = 0.5 // ±12 hours
|
||||
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
|
||||
if (delta > 0.5) delta = 1 - delta // wrap shortest arc
|
||||
if (delta * synodic <= thresholdDays) return p.s
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// Export all functions and constants
|
||||
// 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,
|
||||
DAY_MS,
|
||||
WEEK_MS,
|
||||
isoWeekInfo,
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
DEFAULT_TZ,
|
||||
// core tz helpers
|
||||
makeTZDate,
|
||||
toLocalString,
|
||||
fromLocalString,
|
||||
// recurrence
|
||||
getMondayOfISOWeek,
|
||||
mondayIndex,
|
||||
getOccurrenceIndex,
|
||||
getOccurrenceDate,
|
||||
getVirtualOccurrenceEndDate,
|
||||
// formatting & localization
|
||||
pad,
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
@@ -217,5 +368,14 @@ export {
|
||||
reorderByFirstDay,
|
||||
getLocalizedMonthName,
|
||||
formatDateRange,
|
||||
formatDateShort,
|
||||
formatDateLong,
|
||||
formatTodayString,
|
||||
lunarPhaseSymbol,
|
||||
// iso helpers re-export
|
||||
getISOWeek,
|
||||
getISOWeekYear,
|
||||
// constructors
|
||||
TZDate,
|
||||
UTCDate,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// holidays.js — Holiday utilities using date-holidays package
|
||||
import Holidays from 'date-holidays'
|
||||
|
||||
let holidaysInstance = null
|
||||
let currentCountry = null
|
||||
let currentState = null
|
||||
let currentRegion = null
|
||||
let holidayCache = new Map()
|
||||
let yearCache = new Map()
|
||||
|
||||
/**
|
||||
* Initialize holidays for a specific country/region
|
||||
* @param {string} country - Country code (e.g., 'US', 'GB', 'DE')
|
||||
* @param {string} [state] - State/province code (e.g., 'CA' for California)
|
||||
* @param {string} [region] - Region code
|
||||
*/
|
||||
export function initializeHolidays(country, state = null, region = null) {
|
||||
if (!country) {
|
||||
console.warn('No country provided for holiday initialization')
|
||||
holidaysInstance = null
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
holidaysInstance = new Holidays(country, state, region)
|
||||
currentCountry = country
|
||||
currentState = state
|
||||
currentRegion = region
|
||||
|
||||
holidayCache.clear()
|
||||
yearCache.clear()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize holidays for', country, state, region, error)
|
||||
holidaysInstance = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get holidays for a specific year
|
||||
* @param {number} year - The year to get holidays for
|
||||
* @returns {Array} Array of holiday objects
|
||||
*/
|
||||
export function getHolidaysForYear(year) {
|
||||
if (!holidaysInstance) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (yearCache.has(year)) {
|
||||
return yearCache.get(year)
|
||||
}
|
||||
|
||||
try {
|
||||
const holidays = holidaysInstance.getHolidays(year)
|
||||
yearCache.set(year, holidays)
|
||||
return holidays
|
||||
} catch (error) {
|
||||
console.warn('Failed to get holidays for year', year, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get holiday for a specific date
|
||||
* @param {string|Date} date - Date in YYYY-MM-DD format or Date object
|
||||
* @returns {Object|null} Holiday object or null if no holiday
|
||||
*/
|
||||
export function getHolidayForDate(date) {
|
||||
if (!holidaysInstance) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheKey = typeof date === 'string' ? date : date.toISOString().split('T')[0]
|
||||
if (holidayCache.has(cacheKey)) {
|
||||
return holidayCache.get(cacheKey)
|
||||
}
|
||||
|
||||
try {
|
||||
let dateObj
|
||||
if (typeof date === 'string') {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
dateObj = new Date(year, month - 1, day)
|
||||
} else {
|
||||
dateObj = date
|
||||
}
|
||||
|
||||
const year = dateObj.getFullYear()
|
||||
const holidays = getHolidaysForYear(year)
|
||||
|
||||
const holiday = holidays.find((h) => {
|
||||
const holidayDate = new Date(h.date)
|
||||
return (
|
||||
holidayDate.getFullYear() === dateObj.getFullYear() &&
|
||||
holidayDate.getMonth() === dateObj.getMonth() &&
|
||||
holidayDate.getDate() === dateObj.getDate()
|
||||
)
|
||||
})
|
||||
|
||||
const result = holiday || null
|
||||
holidayCache.set(cacheKey, result)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('Failed to get holiday for date', date, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is a holiday
|
||||
* @param {string|Date} date - Date in YYYY-MM-DD format or Date object
|
||||
* @returns {boolean} True if the date is a holiday
|
||||
*/
|
||||
export function isHoliday(date) {
|
||||
return getHolidayForDate(date) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available countries for holidays
|
||||
* @returns {Array} Array of country codes
|
||||
*/
|
||||
export function getAvailableCountries() {
|
||||
try {
|
||||
const holidays = new Holidays()
|
||||
const countries = holidays.getCountries()
|
||||
|
||||
// The getCountries method might return an object, convert to array of keys
|
||||
if (countries && typeof countries === 'object') {
|
||||
return Array.isArray(countries) ? countries : Object.keys(countries)
|
||||
}
|
||||
|
||||
return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] // Fallback
|
||||
} catch (error) {
|
||||
console.warn('Failed to get available countries', error)
|
||||
return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] // Fallback to common countries
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available states/regions for a country
|
||||
* @param {string} country - Country code
|
||||
* @returns {Array} Array of state/region codes
|
||||
*/
|
||||
export function getAvailableStates(country) {
|
||||
try {
|
||||
if (!country) return []
|
||||
|
||||
const holidays = new Holidays()
|
||||
const states = holidays.getStates(country)
|
||||
|
||||
// The getStates method might return an object, convert to array of keys
|
||||
if (states && typeof states === 'object') {
|
||||
return Array.isArray(states) ? states : Object.keys(states)
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (error) {
|
||||
console.warn('Failed to get available states for', country, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get holiday configuration info
|
||||
* @returns {Object} Current holiday configuration
|
||||
*/
|
||||
export function getHolidayConfig() {
|
||||
return {
|
||||
country: currentCountry,
|
||||
state: currentState,
|
||||
region: currentRegion,
|
||||
initialized: !!holidaysInstance,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize with US holidays by default
|
||||
initializeHolidays('US')
|
||||
Reference in New Issue
Block a user