Date cleanup / refactoring

This commit is contained in:
Leo Vasanko 2025-08-23 21:26:22 -06:00
parent cb0ac1eaf0
commit e78ced2383
8 changed files with 244 additions and 497 deletions

View File

@ -25,7 +25,8 @@ import {
getLocalizedWeekdayNames, getLocalizedWeekdayNames,
getLocaleWeekendDays, getLocaleWeekendDays,
getLocaleFirstDay, getLocaleFirstDay,
isoWeekInfo, getISOWeek,
getISOWeekYear,
fromLocalString, fromLocalString,
toLocalString, toLocalString,
mondayIndex, mondayIndex,
@ -85,7 +86,7 @@ const updateVisibleWeeks = () => {
const topDisplayIndex = Math.floor(scrollTop / rowHeight.value) const topDisplayIndex = Math.floor(scrollTop / rowHeight.value)
const topVW = topDisplayIndex + minVirtualWeek.value const topVW = topDisplayIndex + minVirtualWeek.value
const monday = getMondayForVirtualWeek(topVW) const monday = getMondayForVirtualWeek(topVW)
const { year } = isoWeekInfo(monday) const year = getISOWeekYear(monday)
if (calendarStore.viewYear !== year) { if (calendarStore.viewYear !== year) {
calendarStore.setViewYear(year) calendarStore.setViewYear(year)
} }
@ -126,7 +127,7 @@ const handleWheel = (e) => {
const navigateToYear = (targetYear, weekIndex) => { const navigateToYear = (targetYear, weekIndex) => {
const monday = getMondayForVirtualWeek(weekIndex) const monday = getMondayForVirtualWeek(weekIndex)
const { week } = isoWeekInfo(monday) const week = getISOWeek(monday)
const jan4 = new Date(targetYear, 0, 4) const jan4 = new Date(targetYear, 0, 4)
const jan4Monday = addDays(jan4, -mondayIndex(jan4)) const jan4Monday = addDays(jan4, -mondayIndex(jan4))
const targetMonday = addDays(jan4Monday, (week - 1) * 7) const targetMonday = addDays(jan4Monday, (week - 1) * 7)

View File

@ -1,7 +1,12 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } from '@/utils/date' import {
getLocalizedWeekdayNames,
reorderByFirstDay,
getISOWeek,
getISOWeekYear,
} from '@/utils/date'
import Numeric from '@/components/Numeric.vue' import Numeric from '@/components/Numeric.vue'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
@ -27,7 +32,7 @@ const topVirtualWeek = computed(() => {
const currentYear = computed(() => { const currentYear = computed(() => {
const weekStart = addDays(baseDate.value, topVirtualWeek.value * 7) const weekStart = addDays(baseDate.value, topVirtualWeek.value * 7)
const anchor = addDays(weekStart, (4 - weekStart.getDay() + 7) % 7) const anchor = addDays(weekStart, (4 - weekStart.getDay() + 7) % 7)
return isoWeekInfo(anchor).year return getISOWeekYear(anchor)
}) })
function virtualWeekOf(d) { function virtualWeekOf(d) {
@ -53,10 +58,10 @@ function changeYear(y) {
// Anchor Thursday of current calendar week // Anchor Thursday of current calendar week
const curCalWeekStart = addDays(baseDate.value, vw * 7) const curCalWeekStart = addDays(baseDate.value, vw * 7)
const curAnchorThu = addDays(curCalWeekStart, (4 - curCalWeekStart.getDay() + 7) % 7) const curAnchorThu = addDays(curCalWeekStart, (4 - curCalWeekStart.getDay() + 7) % 7)
let { week: isoW } = isoWeekInfo(curAnchorThu) let isoW = getISOWeek(curAnchorThu)
// Build Monday of ISO week // Build Monday of ISO week
let weekMon = isoWeekMonday(y, isoW) let weekMon = isoWeekMonday(y, isoW)
if (isoWeekInfo(weekMon).year !== y) { if (getISOWeekYear(weekMon) !== y) {
isoW-- isoW--
weekMon = isoWeekMonday(y, isoW) weekMon = isoWeekMonday(y, isoW)
} }

View File

@ -6,7 +6,6 @@ import CalendarWeek from '@/components/CalendarWeek.vue'
import Jogwheel from '@/components/Jogwheel.vue' import Jogwheel from '@/components/Jogwheel.vue'
import SettingsDialog from '@/components/SettingsDialog.vue' import SettingsDialog from '@/components/SettingsDialog.vue'
import { import {
isoWeekInfo,
getLocalizedMonthName, getLocalizedMonthName,
monthAbbr, monthAbbr,
lunarPhaseSymbol, lunarPhaseSymbol,
@ -17,6 +16,7 @@ import {
getMondayOfISOWeek, getMondayOfISOWeek,
getOccurrenceIndex, getOccurrenceIndex,
getVirtualOccurrenceEndDate, getVirtualOccurrenceEndDate,
getISOWeek,
} from '@/utils/date' } from '@/utils/date'
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date' import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
import { addDays, differenceInCalendarDays } from 'date-fns' import { addDays, differenceInCalendarDays } from 'date-fns'
@ -134,7 +134,7 @@ function getFirstDayForVirtualWeek(virtualWeek) {
function createWeek(virtualWeek) { function createWeek(virtualWeek) {
const firstDay = getFirstDayForVirtualWeek(virtualWeek) const firstDay = getFirstDayForVirtualWeek(virtualWeek)
const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7) const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
const weekNumber = isoWeekInfo(isoAnchor).week const weekNumber = getISOWeek(isoAnchor)
const days = [] const days = []
let cur = new Date(firstDay) let cur = new Date(firstDay)
let hasFirst = false let hasFirst = false

View File

@ -4,7 +4,13 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import BaseDialog from './BaseDialog.vue' import BaseDialog from './BaseDialog.vue'
import WeekdaySelector from './WeekdaySelector.vue' import WeekdaySelector from './WeekdaySelector.vue'
import Numeric from './Numeric.vue' import Numeric from './Numeric.vue'
import { addDaysStr, getMondayOfISOWeek, fromLocalString, toLocalString, DEFAULT_TZ } from '@/utils/date' import {
addDaysStr,
getMondayOfISOWeek,
fromLocalString,
toLocalString,
DEFAULT_TZ,
} from '@/utils/date'
import { addDays, addMonths } from 'date-fns' import { addDays, addMonths } from 'date-fns'
const props = defineProps({ const props = defineProps({

View File

@ -7,7 +7,11 @@
<!-- Event spans will be rendered here --> <!-- Event spans will be rendered here -->
</div> </div>
</div> </div>
<div v-if="monthLabel" class="month-name-label" :style="{ height: `${monthLabel.weeksSpan * 64}px` }"> <div
v-if="monthLabel"
class="month-name-label"
:style="{ height: `${monthLabel.weeksSpan * 64}px` }"
>
<span>{{ monthLabel.name }} '{{ monthLabel.year }}</span> <span>{{ monthLabel.name }} '{{ monthLabel.year }}</span>
</div> </div>
</div> </div>
@ -16,19 +20,23 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import DayCell from './DayCell.vue' import DayCell from './DayCell.vue'
import { isoWeekInfo, toLocalString, getLocalizedMonthName, monthAbbr, DEFAULT_TZ } from '@/utils/date' import {
toLocalString,
getLocalizedMonthName,
monthAbbr,
DEFAULT_TZ,
getISOWeek,
} from '@/utils/date'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
const props = defineProps({ const props = defineProps({
week: { week: {
type: Object, type: Object,
required: true required: true,
} },
}) })
const weekNumber = computed(() => { const weekNumber = computed(() => getISOWeek(props.week.monday))
return isoWeekInfo(props.week.monday).week
})
const days = computed(() => { const days = computed(() => {
const d = new Date(props.week.monday) const d = new Date(props.week.monday)
@ -41,7 +49,7 @@ const days = computed(() => {
dayOfMonth: d.getDate(), dayOfMonth: d.getDate(),
month: d.getMonth(), month: d.getMonth(),
isFirstDayOfMonth: d.getDate() === 1, isFirstDayOfMonth: d.getDate() === 1,
monthClass: monthAbbr[d.getMonth()] monthClass: monthAbbr[d.getMonth()],
}) })
d.setTime(addDays(d, 1).getTime()) d.setTime(addDays(d, 1).getTime())
} }
@ -49,7 +57,7 @@ const days = computed(() => {
}) })
const monthLabel = computed(() => { const monthLabel = computed(() => {
const firstDayOfMonth = days.value.find(d => d.isFirstDayOfMonth) const firstDayOfMonth = days.value.find((d) => d.isFirstDayOfMonth)
if (!firstDayOfMonth) return null if (!firstDayOfMonth) return null
const month = firstDayOfMonth.month const month = firstDayOfMonth.month
@ -61,7 +69,7 @@ const monthLabel = computed(() => {
return { return {
name: getLocalizedMonthName(month), name: getLocalizedMonthName(month),
year: String(year).slice(-2), year: String(year).slice(-2),
weeksSpan weeksSpan,
} }
}) })
</script> </script>

View File

@ -593,7 +593,10 @@ export const useCalendarStore = defineStore('calendar', {
} }
snapshot.startDate = newStartStr snapshot.startDate = newStartStr
snapshot.endDate = toLocalString(addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays), DEFAULT_TZ) snapshot.endDate = toLocalString(
addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays),
DEFAULT_TZ,
)
// Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift // Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift
if ( if (
mode === 'move' && mode === 'move' &&

View File

@ -1,524 +1,244 @@
// date-utils.js — Date handling utilities for the calendar (refactored to use date-fns/date-fns-tz) // date-utils.js — Restored & clean utilities (date-fns + timezone aware)
import { import * as dateFns from 'date-fns'
addDays,
differenceInCalendarDays,
differenceInCalendarMonths,
format,
getDate,
getDay,
getDaysInMonth,
getMonth,
getYear,
isAfter,
isBefore,
isEqual,
parseISO,
startOfDay,
} from 'date-fns'
import { fromZonedTime, toZonedTime } from 'date-fns-tz' import { fromZonedTime, toZonedTime } from 'date-fns-tz'
// We expose a simple alias TZDate for clarity. date-fns by default works with native Date objects.
// Consumers can pass an optional timeZone; if omitted, local time zone is assumed.
const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
// Helper to create a zoned date (keeps wall-clock components in provided TZ) // 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',
]
// 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) { function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) {
const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String( const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String(
day day,
).padStart(2, '0')}` ).padStart(2, '0')}`
// Interpret as start of day in target zone
const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone) const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone)
return toZonedTime(utcDate, timeZone) return toZonedTime(utcDate, timeZone)
} }
const monthAbbr = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'] /**
const DAY_MS = 86400000 * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate).
const WEEK_MS = 7 * DAY_MS */
const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) =>
makeTZDate(year, monthIndex, day, timeZone)
/** /**
* Get ISO week information for a given date * Construct a UTC-based date/time (wrapper for Date.UTC for consistency).
* @param {Date} date - The date to get week info for
* @returns {Object} Object containing week number and year
*/ */
const isoWeekInfo = (date) => { const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) =>
// ISO week: Thursday algorithm new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms))
const d = new Date(Date.UTC(getYear(date), getMonth(date), getDate(date)))
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 }
}
/**
* 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
*/
function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) {
return format(toZonedTime(date, timeZone), 'yyyy-MM-dd') 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, timeZone = DEFAULT_TZ) { function fromLocalString(dateString, timeZone = DEFAULT_TZ) {
const parsed = parseISO(dateString) if (!dateString) return makeTZDate(1970, 0, 1, timeZone)
const parsed = dateFns.parseISO(dateString)
const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone) const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone)
return toZonedTime(utcDate, timeZone) || parsed return toZonedTime(utcDate, timeZone) || parsed
} }
/**
* Get the Monday of the ISO week for a given date
* @param {Date} date - The date to get the Monday for
* @returns {Date} Date object representing the Monday of the ISO week
*/
function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) {
const d = startOfDay(toZonedTime(date, timeZone)) const d = toZonedTime(date, timeZone)
const dayOfWeek = (getDay(d) + 6) % 7 const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0
return addDays(d, -dayOfWeek) return dateFns.addDays(dateFns.startOfDay(d), -dow)
} }
/** const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7
* 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) => (getDay(d) + 6) % 7
/** // Count how many days in [startDate..endDate] match the boolean `pattern` array
* Calculate the occurrence index for a repeating weekly event on a specific date function countPatternDaysInInterval(startDate, endDate, patternArr) {
* @param {Object} event - The event object with repeat info const days = dateFns.eachDayOfInterval({
* @param {string} dateStr - The date string (YYYY-MM-DD) to check start: dateFns.startOfDay(startDate),
* @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not valid end: dateFns.startOfDay(endDate),
*/ })
return days.reduce((c, d) => c + (patternArr[dateFns.getDay(d)] ? 1 : 0), 0)
}
// Recurrence: Weekly ------------------------------------------------------
function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event.isRepeating || event.repeat !== 'weeks') return null if (!event?.isRepeating || event.repeat !== 'weeks') return null
const pattern = event.repeatWeekdays || [] const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return null if (!pattern.some(Boolean)) return null
const d = fromLocalString(dateStr, timeZone) const target = fromLocalString(dateStr, timeZone)
const dow = getDay(d)
if (!pattern[dow]) return null
const baseStart = fromLocalString(event.startDate, 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 = event.repeatInterval || 1 const interval = event.repeatInterval || 1
// Check if date resides in a week block that aligns with interval
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone) const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
const currentBlockStart = getMondayOfISOWeek(d, timeZone) const currentBlockStart = getMondayOfISOWeek(target, timeZone)
const WEEK_MS = 7 * 86400000 // Number of weeks between block starts (each block start is a Monday)
const blocksDiff = Math.floor((currentBlockStart - baseBlockStart) / WEEK_MS) const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart)
if (weekDiff < 0 || weekDiff % interval !== 0) return null
if (blocksDiff < 0 || blocksDiff % interval !== 0) return null // Same ISO week as base: count pattern days from baseStart up to target (inclusive)
if (weekDiff === 0) {
// For same week as base start, count from base start to target const n = countPatternDaysInInterval(baseStart, target, pattern) - 1
if (isEqual(currentBlockStart, baseBlockStart)) { return n < 0 || n >= event.repeatCount ? null : n
// Special handling for the first week - only count occurrences on or after base date
if (d.getTime() === baseStart.getTime()) {
return 0 // Base occurrence is always index 0
} }
if (d < baseStart) { const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
return null // Dates before base start in same week are not valid occurrences // 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)
const n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
return n >= event.repeatCount ? null : n
} }
let occurrenceIndex = 0 // Recurrence: Monthly -----------------------------------------------------
let cursor = new Date(baseStart)
// Count the base occurrence first
if (pattern[getDay(cursor)]) occurrenceIndex++
// Move to the next day and count until we reach the target
cursor = addDays(cursor, 1)
while (cursor <= d) {
if (pattern[getDay(cursor)]) occurrenceIndex++
cursor = addDays(cursor, 1)
}
// Subtract 1 because we want the index, not the count
occurrenceIndex--
// Check against repeat count limit
if (event.repeatCount !== 'unlimited') {
const limit = parseInt(event.repeatCount, 10)
if (isNaN(limit) || occurrenceIndex >= limit) return null
}
return occurrenceIndex
}
// For different weeks, calculate based on complete intervals
// Calculate how many pattern days actually occur in the first week (from base start onward)
let firstWeekPatternDays = 0
let firstWeekCursor = new Date(baseStart)
const firstWeekEnd = addDays(new Date(baseBlockStart), 6) // End of first week (Sunday)
while (firstWeekCursor <= firstWeekEnd) {
if (pattern[getDay(firstWeekCursor)]) firstWeekPatternDays++
firstWeekCursor = addDays(firstWeekCursor, 1)
}
// For subsequent complete intervals, use the full pattern count
const fullWeekdaysPerInterval = pattern.filter(Boolean).length
const completeIntervals = blocksDiff / interval
// First interval uses actual first week count, remaining intervals use full count
let occurrenceIndex = firstWeekPatternDays + (completeIntervals - 1) * fullWeekdaysPerInterval
// Add occurrences from the current week up to the target date
cursor = new Date(currentBlockStart)
while (cursor < d) {
if (pattern[getDay(cursor)]) occurrenceIndex++
cursor = addDays(cursor, 1)
}
// Check against repeat count limit
if (event.repeatCount !== 'unlimited') {
const limit = parseInt(event.repeatCount, 10)
if (isNaN(limit) || occurrenceIndex >= limit) return null
}
return occurrenceIndex
}
/**
* Calculate the occurrence index for a repeating monthly event on a specific date
* @param {Object} event - The event object with repeat info
* @param {string} dateStr - The date string (YYYY-MM-DD) to check
* @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not valid
*/
function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event.isRepeating || event.repeat !== 'months') return null if (!event?.isRepeating || event.repeat !== 'months') return null
const baseStart = fromLocalString(event.startDate, timeZone) const baseStart = fromLocalString(event.startDate, timeZone)
const d = fromLocalString(dateStr, timeZone) const d = fromLocalString(dateStr, timeZone)
const diffMonths = differenceInCalendarMonths(d, baseStart) const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
if (diffMonths < 0) return null if (diffMonths < 0) return null
const interval = event.repeatInterval || 1 const interval = event.repeatInterval || 1
if (diffMonths % interval !== 0) return null if (diffMonths % interval !== 0) return null
const baseDay = dateFns.getDate(baseStart)
// Check day match (clamped for shorter months) const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
const baseDay = getDate(baseStart) if (dateFns.getDate(d) !== effectiveDay) return null
const daysInMonth = getDaysInMonth(d) const n = diffMonths / interval
const effectiveDay = Math.min(baseDay, daysInMonth) return n >= event.repeatCount ? null : n
if (getDate(d) !== effectiveDay) return null
const occurrenceIndex = diffMonths / interval
// Check against repeat count limit
if (event.repeatCount !== 'unlimited') {
const limit = parseInt(event.repeatCount, 10)
if (isNaN(limit) || occurrenceIndex >= limit) return null
} }
return occurrenceIndex
}
/**
* Check if a repeating event occurs on a specific date and return occurrence index
* @param {Object} event - The event object with repeat info
* @param {string} dateStr - The date string (YYYY-MM-DD) to check
* @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not occurring
*/
function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event || !event.isRepeating || event.repeat === 'none') return null if (!event?.isRepeating || event.repeat === 'none') return null
if (dateStr < event.startDate) return null if (dateStr < event.startDate) return null
if (event.repeat === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
if (event.repeat === 'weeks') { if (event.repeat === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
} else if (event.repeat === 'months') {
return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
}
return null return null
} }
/**
* Calculate the end date for a virtual occurrence of a repeating event
* @param {Object} event - The base event object
* @param {string} occurrenceStartDate - The start date of the occurrence (YYYY-MM-DD)
* @returns {string} The end date of the occurrence (YYYY-MM-DD)
*/
function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) { function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
const baseStart = fromLocalString(event.startDate, timeZone) const baseStart = fromLocalString(event.startDate, timeZone)
const baseEnd = fromLocalString(event.endDate, timeZone) const baseEnd = fromLocalString(event.endDate, timeZone)
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart)) const spanDays = Math.max(0, dateFns.differenceInCalendarDays(baseEnd, baseStart))
const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone) const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone)
const occurrenceEnd = addDays(occurrenceStart, spanDays) return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone)
return toLocalString(occurrenceEnd, timeZone)
} }
/** // Utility formatting & localization ---------------------------------------
* Check if a repeating event occurs on or spans through a specific date
* @param {Object} event - The event object with repeat info
* @param {string} dateStr - The date string (YYYY-MM-DD) to check
* @returns {boolean} True if the event occurs on or spans through the date
*/
function occursOnOrSpansDate(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event || !event.isRepeating || event.repeat === 'none') return false
// Check if this is the base event spanning naturally
if (dateStr >= event.startDate && dateStr <= event.endDate) return true
// For virtual occurrences, we need to check if any occurrence spans through this date
const baseStart = fromLocalString(event.startDate, timeZone)
const baseEnd = fromLocalString(event.endDate, timeZone)
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
if (spanDays === 0) {
// Single day event - just check if it occurs on this date
return getOccurrenceIndex(event, dateStr) !== null
}
// Multi-day event - check if any occurrence's span includes this date
const targetDate = fromLocalString(dateStr, timeZone)
if (event.repeat === 'weeks') {
const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return false
const interval = event.repeatInterval || 1
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
const WEEK_MS = 7 * 86400000
// Check a reasonable range of weeks around the target date
for (
let weekOffset = -Math.ceil(spanDays / 7) - 1;
weekOffset <= Math.ceil(spanDays / 7) + 1;
weekOffset++
) {
const weekStart = addDays(baseBlockStart, weekOffset * 7)
// Check if this week aligns with the interval
const blocksDiff = Math.floor((weekStart - baseBlockStart) / WEEK_MS)
if (blocksDiff < 0 || blocksDiff % interval !== 0) continue
// Check each day in this week
for (let day = 0; day < 7; day++) {
const candidateStart = addDays(weekStart, day)
// Skip if before base start
if (isBefore(candidateStart, baseStart)) continue
// Check if this day matches the pattern
if (!pattern[getDay(candidateStart)]) continue
// Check repeat count limit
const occIndex = getWeeklyOccurrenceIndex(event, toLocalString(candidateStart, timeZone), timeZone)
if (occIndex === null) continue
// Calculate end date for this occurrence
const candidateEnd = addDays(candidateStart, spanDays)
// Check if target date falls within this occurrence's span
if (!isBefore(targetDate, candidateStart) && !isAfter(targetDate, candidateEnd)) {
return true
}
}
}
} else if (event.repeat === 'months') {
const interval = event.repeatInterval || 1
const baseDay = getDate(baseStart)
// Check a reasonable range of months around the target date
// targetYear & targetMonth not needed in refactored logic
const baseYear = getYear(baseStart)
const baseMonth = getMonth(baseStart)
for (let monthOffset = -spanDays * 2; monthOffset <= spanDays * 2; monthOffset++) {
const candidateYear = baseYear + Math.floor((baseMonth + monthOffset) / 12)
const candidateMonth = (baseMonth + monthOffset + 12) % 12
// Check if this month aligns with the interval
const diffMonths = (candidateYear - baseYear) * 12 + (candidateMonth - baseMonth)
if (diffMonths < 0 || diffMonths % interval !== 0) continue
// Calculate the actual day (clamped for shorter months)
const daysInMonth = getDaysInMonth(new Date(candidateYear, candidateMonth, 1))
const effectiveDay = Math.min(baseDay, daysInMonth)
const candidateStart = makeTZDate(candidateYear, candidateMonth, effectiveDay)
// Skip if before base start
if (isBefore(candidateStart, baseStart)) continue
// Check repeat count limit
const occIndex = getMonthlyOccurrenceIndex(event, toLocalString(candidateStart, timeZone), timeZone)
if (occIndex === null) continue
// Calculate end date for this occurrence
const candidateEnd = addDays(candidateStart, spanDays)
// Check if target date falls within this occurrence's span
if (!isBefore(targetDate, candidateStart) && !isAfter(targetDate, candidateEnd)) {
return true
}
}
}
return false
} /**
* Pad a number with leading zeros to make it 2 digits
* @param {number} n - Number to pad
* @returns {string} Padded string
*/
const pad = (n) => String(n).padStart(2, '0') 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, timeZone = DEFAULT_TZ) { function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) {
const a = fromLocalString(aStr, timeZone) const a = fromLocalString(aStr, timeZone)
const b = fromLocalString(bStr, timeZone) const b = fromLocalString(bStr, timeZone)
return Math.abs(differenceInCalendarDays(startOfDay(a), startOfDay(b))) + 1 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, timeZone = DEFAULT_TZ) {
const d = fromLocalString(str, timeZone)
return toLocalString(addDays(d, n), timeZone)
}
/**
* Get localized weekday names starting from Monday
* @returns {Array<string>} Array of localized weekday names
*/
function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
const res = []
const base = makeTZDate(2025, 0, 6, timeZone) // Monday
for (let i = 0; i < 7; i++) {
const d = addDays(base, i)
res.push(
new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format(d)
) )
} }
return res
function addDaysStr(str, n, timeZone = DEFAULT_TZ) {
return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone)
}
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() { function getLocaleFirstDay() {
try {
return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 return new Intl.Locale(navigator.language).weekInfo.firstDay % 7
} catch {
return 1 // Default to Monday if locale info not available
}
} }
/**
* 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() { function getLocaleWeekendDays() {
try { const wk = new Intl.Locale(navigator.language).weekInfo.weekend || [6, 7]
const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend const set = new Set(wk.map((d) => d % 7))
const dayidx = new Set(localeWeekend) return Array.from({ length: 7 }, (_, i) => set.has(i))
return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7))
} catch {
return [true, false, false, false, false, false, true] // Default to Saturday/Sunday weekend
}
} }
/**
* 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) { function reorderByFirstDay(days, firstDay) {
return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) 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, timeZone = DEFAULT_TZ) { function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) {
const d = makeTZDate(2025, idx, 1, timeZone) const d = makeTZDate(2025, idx, 1, timeZone)
return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d) 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, timeZone = DEFAULT_TZ) { function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) {
if (toLocalString(startDate, timeZone) === toLocalString(endDate, timeZone)) const a = toLocalString(startDate, timeZone)
return toLocalString(startDate, timeZone) const b = toLocalString(endDate, timeZone)
const startISO = toLocalString(startDate, timeZone) if (a === b) return a
const endISO = toLocalString(endDate, timeZone) const [ay, am] = a.split('-')
const [sy, sm] = startISO.split('-') const [by, bm, bd] = b.split('-')
const [ey, em, ed] = endISO.split('-') if (ay === by && am === bm) return `${a}/${bd}`
if (sy === ey && sm === em) return `${startISO}/${ed}` if (ay === by) return `${a}/${bm}-${bd}`
if (sy === ey) return `${startISO}/${em}-${ed}` return `${a}/${b}`
return `${startISO}/${endISO}`
} }
/**
* 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) { function lunarPhaseSymbol(date) {
// Reference new moon: 2000-01-06 18:14 UTC (J2000 era), often used in approximations // Reference new moon (J2000 era) used for approximate phase calculations
const ref = Date.UTC(2000, 0, 6, 18, 14, 0) const ref = UTCDate(2000, 0, 6, 18, 14, 0)
const synodic = 29.530588853 // days const obs = new Date(date)
// Use UTC noon of given date to reduce timezone edge effects obs.setHours(12, 0, 0, 0)
const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0) const synodic = 29.530588853 // mean synodic month length in days
const daysSince = (dUTC - ref) / DAY_MS const daysSince = dateFns.differenceInMinutes(obs, ref) / 60 / 24
const phase = (((daysSince / synodic) % 1) + 1) % 1 const phase = (((daysSince / synodic) % 1) + 1) % 1 // normalize to [0,1)
const phases = [ const phases = [
{ t: 0.0, s: '🌑' }, // New Moon { t: 0.0, s: '🌑' }, // New
{ t: 0.25, s: '🌓' }, // First Quarter { t: 0.25, s: '🌓' }, // First Quarter
{ t: 0.5, s: '🌕' }, // Full Moon { t: 0.5, s: '🌕' }, // Full
{ t: 0.75, s: '🌗' }, // Last Quarter { t: 0.75, s: '🌗' }, // Last Quarter
] ]
// threshold in days from exact phase to still count for this date const thresholdDays = 0.5 // within ~12h of exact phase
const thresholdDays = 0.5 // ±12 hours
for (const p of phases) { for (const p of phases) {
let delta = Math.abs(phase - p.t) 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 if (delta * synodic <= thresholdDays) return p.s
} }
return '' return ''
} }
// Export all functions and constants // Exports -----------------------------------------------------------------
export { export {
// constants
monthAbbr, monthAbbr,
DAY_MS, DEFAULT_TZ,
WEEK_MS, // core tz helpers
isoWeekInfo, makeTZDate,
toLocalString, toLocalString,
fromLocalString, fromLocalString,
// recurrence
getMondayOfISOWeek, getMondayOfISOWeek,
getWeeklyOccurrenceIndex, mondayIndex,
getMonthlyOccurrenceIndex,
getOccurrenceIndex, getOccurrenceIndex,
getVirtualOccurrenceEndDate, getVirtualOccurrenceEndDate,
occursOnOrSpansDate, // formatting & localization
mondayIndex,
pad, pad,
daysInclusive, daysInclusive,
addDaysStr, addDaysStr,
@ -529,6 +249,10 @@ export {
getLocalizedMonthName, getLocalizedMonthName,
formatDateRange, formatDateRange,
lunarPhaseSymbol, lunarPhaseSymbol,
makeTZDate, // iso helpers re-export
DEFAULT_TZ, getISOWeek,
getISOWeekYear,
// constructors
TZDate,
UTCDate,
} }