Major new version #2

Merged
LeoVasanko merged 86 commits from vol002 into main 2025-08-26 05:58:24 +01:00
9 changed files with 283 additions and 317 deletions
Showing only changes of commit cb0ac1eaf0 - Show all commits

View File

@ -17,6 +17,8 @@
}, },
"dependencies": { "dependencies": {
"date-holidays": "^3.25.1", "date-holidays": "^3.25.1",
"date-fns": "^3.6.0",
"date-fns-tz": "^3.0.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0", "pinia-plugin-persistedstate": "^4.5.0",
"vue": "^3.5.18" "vue": "^3.5.18"

View File

@ -29,7 +29,9 @@ import {
fromLocalString, fromLocalString,
toLocalString, toLocalString,
mondayIndex, mondayIndex,
DEFAULT_TZ,
} from '@/utils/date' } from '@/utils/date'
import { addDays } from 'date-fns'
import WeekRow from './WeekRow.vue' import WeekRow from './WeekRow.vue'
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
@ -45,6 +47,7 @@ const config = {
weekend: getLocaleWeekendDays(), weekend: getLocaleWeekendDays(),
} }
// Anchor Monday (or locale first day) reference date
const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay()) const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay())
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
@ -56,16 +59,11 @@ const isWeekend = (day) => {
} }
const getWeekIndex = (date) => { const getWeekIndex = (date) => {
const monday = new Date(date) const monday = addDays(date, -mondayIndex(date))
monday.setDate(date.getDate() - mondayIndex(date)) return Math.floor((monday.getTime() - baseDate.getTime()) / WEEK_MS)
return Math.floor((monday - baseDate) / WEEK_MS)
} }
const getMondayForVirtualWeek = (virtualWeek) => { const getMondayForVirtualWeek = (virtualWeek) => addDays(baseDate, virtualWeek * 7)
const monday = new Date(baseDate)
monday.setDate(monday.getDate() + virtualWeek * 7)
return monday
}
const computeRowHeight = () => { const computeRowHeight = () => {
const el = document.createElement('div') const el = document.createElement('div')
@ -104,10 +102,7 @@ const updateVisibleWeeks = () => {
const newVisibleWeeks = [] const newVisibleWeeks = []
for (let vw = startVW; vw <= endVW; vw++) { for (let vw = startVW; vw <= endVW; vw++) {
newVisibleWeeks.push({ newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) })
virtualWeek: vw,
monday: getMondayForVirtualWeek(vw),
})
} }
visibleWeeks.value = newVisibleWeeks visibleWeeks.value = newVisibleWeeks
} }
@ -133,10 +128,8 @@ const navigateToYear = (targetYear, weekIndex) => {
const monday = getMondayForVirtualWeek(weekIndex) const monday = getMondayForVirtualWeek(weekIndex)
const { week } = isoWeekInfo(monday) const { week } = isoWeekInfo(monday)
const jan4 = new Date(targetYear, 0, 4) const jan4 = new Date(targetYear, 0, 4)
const jan4Monday = new Date(jan4) const jan4Monday = addDays(jan4, -mondayIndex(jan4))
jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4)) const targetMonday = addDays(jan4Monday, (week - 1) * 7)
const targetMonday = new Date(jan4Monday)
targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7)
scrollToTarget(targetMonday) scrollToTarget(targetMonday)
} }
@ -155,8 +148,7 @@ const scrollToTarget = (target) => {
const goToTodayHandler = () => { const goToTodayHandler = () => {
const today = new Date() const today = new Date()
const top = new Date(today) const top = addDays(today, -21)
top.setDate(top.getDate() - 21)
scrollToTarget(top) scrollToTarget(top)
} }
@ -165,14 +157,13 @@ onMounted(() => {
const minYearDate = new Date(config.min_year, 0, 1) const minYearDate = new Date(config.min_year, 0, 1)
const maxYearLastDay = new Date(config.max_year, 11, 31) const maxYearLastDay = new Date(config.max_year, 11, 31)
const lastWeekMonday = new Date(maxYearLastDay) const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay))
lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay))
minVirtualWeek.value = getWeekIndex(minYearDate) minVirtualWeek.value = getWeekIndex(minYearDate)
const maxVirtualWeek = getWeekIndex(lastWeekMonday) const maxVirtualWeek = getWeekIndex(lastWeekMonday)
totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1 totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1
const initialDate = fromLocalString(calendarStore.today) const initialDate = fromLocalString(calendarStore.today, DEFAULT_TZ)
scrollToTarget(initialDate) scrollToTarget(initialDate)
document.addEventListener('goToToday', goToTodayHandler) document.addEventListener('goToToday', goToTodayHandler)

View File

@ -3,6 +3,7 @@ import { computed } from 'vue'
import { useCalendarStore } from '@/stores/CalendarStore' import { useCalendarStore } from '@/stores/CalendarStore'
import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } from '@/utils/date' import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } from '@/utils/date'
import Numeric from '@/components/Numeric.vue' import Numeric from '@/components/Numeric.vue'
import { addDays } from 'date-fns'
const props = defineProps({ const props = defineProps({
scrollTop: { type: Number, default: 0 }, scrollTop: { type: Number, default: 0 },
@ -24,28 +25,21 @@ const topVirtualWeek = computed(() => {
}) })
const currentYear = computed(() => { const currentYear = computed(() => {
const weekStart = new Date(baseDate.value) const weekStart = addDays(baseDate.value, topVirtualWeek.value * 7)
weekStart.setDate(weekStart.getDate() + topVirtualWeek.value * 7) const anchor = addDays(weekStart, (4 - weekStart.getDay() + 7) % 7)
// ISO anchor Thursday
const anchor = new Date(weekStart)
anchor.setDate(anchor.getDate() + ((4 - anchor.getDay() + 7) % 7))
return isoWeekInfo(anchor).year return isoWeekInfo(anchor).year
}) })
function virtualWeekOf(d) { function virtualWeekOf(d) {
const o = (d.getDay() - calendarStore.config.first_day + 7) % 7 const o = (d.getDay() - calendarStore.config.first_day + 7) % 7
const fd = new Date(d) const fd = addDays(d, -o)
fd.setDate(d.getDate() - o) return Math.floor((fd.getTime() - baseDate.value.getTime()) / WEEK_MS)
return Math.floor((fd - baseDate.value) / WEEK_MS)
} }
function isoWeekMonday(isoYear, isoWeek) { function isoWeekMonday(isoYear, isoWeek) {
const jan4 = new Date(isoYear, 0, 4) const jan4 = new Date(isoYear, 0, 4)
const week1Mon = new Date(jan4) const week1Mon = addDays(jan4, -((jan4.getDay() + 6) % 7))
week1Mon.setDate(week1Mon.getDate() - ((week1Mon.getDay() + 6) % 7)) return addDays(week1Mon, (isoWeek - 1) * 7)
const target = new Date(week1Mon)
target.setDate(target.getDate() + (isoWeek - 1) * 7)
return target
} }
function changeYear(y) { function changeYear(y) {
@ -57,10 +51,8 @@ function changeYear(y) {
const weekStartScroll = (vw - props.minVirtualWeek) * props.rowHeight const weekStartScroll = (vw - props.minVirtualWeek) * props.rowHeight
const frac = Math.max(0, Math.min(1, (props.scrollTop - weekStartScroll) / props.rowHeight)) const frac = Math.max(0, Math.min(1, (props.scrollTop - weekStartScroll) / props.rowHeight))
// Anchor Thursday of current calendar week // Anchor Thursday of current calendar week
const curCalWeekStart = new Date(baseDate.value) const curCalWeekStart = addDays(baseDate.value, vw * 7)
curCalWeekStart.setDate(curCalWeekStart.getDate() + vw * 7) const curAnchorThu = addDays(curCalWeekStart, (4 - curCalWeekStart.getDay() + 7) % 7)
const curAnchorThu = new Date(curCalWeekStart)
curAnchorThu.setDate(curAnchorThu.getDate() + ((4 - curAnchorThu.getDay() + 7) % 7))
let { week: isoW } = isoWeekInfo(curAnchorThu) let { week: isoW } = isoWeekInfo(curAnchorThu)
// Build Monday of ISO week // Build Monday of ISO week
let weekMon = isoWeekMonday(y, isoW) let weekMon = isoWeekMonday(y, isoW)
@ -70,8 +62,7 @@ function changeYear(y) {
} }
// Align to configured first day // Align to configured first day
const shift = (weekMon.getDay() - calendarStore.config.first_day + 7) % 7 const shift = (weekMon.getDay() - calendarStore.config.first_day + 7) % 7
const calWeekStart = new Date(weekMon) const calWeekStart = addDays(weekMon, -shift)
calWeekStart.setDate(calWeekStart.getDate() - shift)
const targetVW = virtualWeekOf(calWeekStart) const targetVW = virtualWeekOf(calWeekStart)
let scrollTop = (targetVW - props.minVirtualWeek) * props.rowHeight + frac * props.rowHeight let scrollTop = (targetVW - props.minVirtualWeek) * props.rowHeight + frac * props.rowHeight
if (Math.abs(scrollTop - props.scrollTop) < 0.5) scrollTop += 0.25 * props.rowHeight if (Math.abs(scrollTop - props.scrollTop) < 0.5) scrollTop += 0.25 * props.rowHeight

View File

@ -18,7 +18,8 @@ import {
getOccurrenceIndex, getOccurrenceIndex,
getVirtualOccurrenceEndDate, getVirtualOccurrenceEndDate,
} from '@/utils/date' } from '@/utils/date'
import { toLocalString, fromLocalString } from '@/utils/date' import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
import { addDays, differenceInCalendarDays } from 'date-fns'
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
const viewport = ref(null) const viewport = ref(null)
@ -49,18 +50,16 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000
const minVirtualWeek = computed(() => { const minVirtualWeek = computed(() => {
const date = new Date(calendarStore.minYear, 0, 1) const date = new Date(calendarStore.minYear, 0, 1)
const firstDayOfWeek = new Date(date)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
firstDayOfWeek.setDate(date.getDate() - dayOffset) const firstDayOfWeek = addDays(date, -dayOffset)
return Math.floor((firstDayOfWeek - baseDate.value) / WEEK_MS) return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
}) })
const maxVirtualWeek = computed(() => { const maxVirtualWeek = computed(() => {
const date = new Date(calendarStore.maxYear, 11, 31) const date = new Date(calendarStore.maxYear, 11, 31)
const firstDayOfWeek = new Date(date)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
firstDayOfWeek.setDate(date.getDate() - dayOffset) const firstDayOfWeek = addDays(date, -dayOffset)
return Math.floor((firstDayOfWeek - baseDate.value) / WEEK_MS) return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
}) })
const totalVirtualWeeks = computed(() => { const totalVirtualWeeks = computed(() => {
@ -123,25 +122,21 @@ function computeRowHeight() {
} }
function getWeekIndex(date) { function getWeekIndex(date) {
const firstDayOfWeek = new Date(date)
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
firstDayOfWeek.setDate(date.getDate() - dayOffset) const firstDayOfWeek = addDays(date, -dayOffset)
return Math.floor((firstDayOfWeek - baseDate.value) / WEEK_MS) return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS)
} }
function getFirstDayForVirtualWeek(virtualWeek) { function getFirstDayForVirtualWeek(virtualWeek) {
const firstDay = new Date(baseDate.value) return addDays(baseDate.value, virtualWeek * 7)
firstDay.setDate(firstDay.getDate() + virtualWeek * 7)
return firstDay
} }
function createWeek(virtualWeek) { function createWeek(virtualWeek) {
const firstDay = getFirstDayForVirtualWeek(virtualWeek) const firstDay = getFirstDayForVirtualWeek(virtualWeek)
const isoAnchor = new Date(firstDay) const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
isoAnchor.setDate(isoAnchor.getDate() + ((4 - isoAnchor.getDay() + 7) % 7))
const weekNumber = isoWeekInfo(isoAnchor).week const weekNumber = isoWeekInfo(isoAnchor).week
const days = [] const days = []
const cur = new Date(firstDay) let cur = new Date(firstDay)
let hasFirst = false let hasFirst = false
let monthToLabel = null let monthToLabel = null
let labelYear = null let labelYear = null
@ -154,7 +149,7 @@ function createWeek(virtualWeek) {
} }
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur) const dateStr = toLocalString(cur, DEFAULT_TZ)
const storedEvents = [] const storedEvents = []
// Find all non-repeating events that occur on this date // Find all non-repeating events that occur on this date
@ -180,23 +175,22 @@ function createWeek(virtualWeek) {
} }
// Check if any virtual occurrence spans this date // Check if any virtual occurrence spans this date
const baseStart = fromLocalString(base.startDate) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseEnd = fromLocalString(base.endDate) const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
const currentDate = fromLocalString(dateStr) const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
let occurrenceFound = false let occurrenceFound = false
// Walk backwards within span to find occurrence start // Walk backwards within span to find occurrence start
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) { for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
const candidateStart = new Date(currentDate) const candidateStart = addDays(currentDate, -offset)
candidateStart.setDate(candidateStart.getDate() - offset) const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
const candidateStartStr = toLocalString(candidateStart)
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr) const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
if (occurrenceIndex !== null) { if (occurrenceIndex !== null) {
// Calculate the end date of this occurrence // Calculate the end date of this occurrence
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr) const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
// Check if this occurrence spans through the current date // Check if this occurrence spans through the current date
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) { if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
@ -258,18 +252,18 @@ function createWeek(virtualWeek) {
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1), dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
events: dayEvents, events: dayEvents,
}) })
cur.setDate(cur.getDate() + 1) cur = addDays(cur, 1)
} }
let monthLabel = null let monthLabel = null
if (hasFirst && monthToLabel !== null) { if (hasFirst && monthToLabel !== null) {
if (labelYear && labelYear <= calendarStore.config.max_year) { if (labelYear && labelYear <= calendarStore.config.max_year) {
let weeksSpan = 0 let weeksSpan = 0
const d = new Date(cur) const d = addDays(cur, -1)
d.setDate(cur.getDate() - 1)
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
d.setDate(cur.getDate() - 1 + i * 7) const probe = addDays(cur, -1 + i * 7)
d.setTime(probe.getTime())
if (d.getMonth() === monthToLabel) weeksSpan++ if (d.getMonth() === monthToLabel) weeksSpan++
} }
@ -296,8 +290,7 @@ function createWeek(virtualWeek) {
} }
function goToToday() { function goToToday() {
const top = new Date(calendarStore.now) const top = addDays(new Date(calendarStore.now), -21)
top.setDate(top.getDate() - 21)
const targetWeekIndex = getWeekIndex(top) const targetWeekIndex = getWeekIndex(top)
scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
if (viewport.value) { if (viewport.value) {
@ -331,8 +324,8 @@ function endDrag(dateStr) {
function calculateSelection(anchorStr, otherStr) { function calculateSelection(anchorStr, otherStr) {
const limit = calendarStore.config.select_days const limit = calendarStore.config.select_days
const anchorDate = fromLocalString(anchorStr) const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
const otherDate = fromLocalString(otherStr) const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
const forward = otherDate >= anchorDate const forward = otherDate >= anchorDate
const span = daysInclusive(anchorStr, otherStr) const span = daysInclusive(anchorStr, otherStr)
@ -344,7 +337,7 @@ function calculateSelection(anchorStr, otherStr) {
if (forward) { if (forward) {
return { startDate: anchorStr, dayCount: limit } return { startDate: anchorStr, dayCount: limit }
} else { } else {
const startDate = addDaysStr(anchorStr, -(limit - 1)) const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
return { startDate, dayCount: limit } return { startDate, dayCount: limit }
} }
} }
@ -448,7 +441,7 @@ watch(
() => calendarStore.config.first_day, () => calendarStore.config.first_day,
() => { () => {
const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value
const currentTopDate = getFirstDayForVirtualWeek(currentTopVW) const currentTopDate = getFirstDayForVirtualWeek(currentTopVW)
requestAnimationFrame(() => { requestAnimationFrame(() => {
const newTopWeekIndex = getWeekIndex(currentTopDate) const newTopWeekIndex = getWeekIndex(currentTopDate)
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value

View File

@ -4,7 +4,8 @@ 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 } from '@/utils/date' import { addDaysStr, getMondayOfISOWeek, fromLocalString, toLocalString, DEFAULT_TZ } from '@/utils/date'
import { addDays, addMonths } from 'date-fns'
const props = defineProps({ const props = defineProps({
selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) }, selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) },
@ -97,7 +98,7 @@ const modalStyle = computed(() => {
function getStartingWeekday(selectionData = null) { function getStartingWeekday(selectionData = null) {
const currentSelection = selectionData || props.selection const currentSelection = selectionData || props.selection
if (!currentSelection.start) return 0 // Default to Sunday if (!currentSelection.start) return 0 // Default to Sunday
const date = new Date(currentSelection.start + 'T00:00:00') const date = fromLocalString(currentSelection.start, DEFAULT_TZ)
const dayOfWeek = date.getDay() // 0=Sunday, 1=Monday, ... const dayOfWeek = date.getDay() // 0=Sunday, 1=Monday, ...
return dayOfWeek // Keep Sunday-first (0=Sunday, 6=Saturday) return dayOfWeek // Keep Sunday-first (0=Sunday, 6=Saturday)
} }
@ -317,9 +318,9 @@ function openEditDialog(payload) {
// Compute occurrenceDate based on occurrenceIndex rather than parsing instanceId // Compute occurrenceDate based on occurrenceIndex rather than parsing instanceId
if (event.isRepeating) { if (event.isRepeating) {
if (event.repeat === 'weeks' && occurrenceIndex >= 0) { if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
const pattern = event.repeatWeekdays || [] const pattern = event.repeatWeekdays || []
const baseStart = new Date(event.startDate + 'T00:00:00') const baseStart = fromLocalString(event.startDate, DEFAULT_TZ)
const baseEnd = new Date(event.endDate + 'T00:00:00') const baseEnd = fromLocalString(event.endDate, DEFAULT_TZ)
if (occurrenceIndex === 0) { if (occurrenceIndex === 0) {
occurrenceDate = baseStart occurrenceDate = baseStart
weekday = baseStart.getDay() weekday = baseStart.getDay()
@ -333,8 +334,7 @@ function openEditDialog(payload) {
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
return diff % interval === 0 return diff % interval === 0
} }
let cur = new Date(baseEnd) let cur = addDays(baseEnd, 1)
cur.setDate(cur.getDate() + 1)
let found = 0 // number of repeat occurrences found so far let found = 0 // number of repeat occurrences found so far
let safety = 0 let safety = 0
while (found < occurrenceIndex && safety < 20000) { while (found < occurrenceIndex && safety < 20000) {
@ -342,17 +342,15 @@ function openEditDialog(payload) {
found++ found++
if (found === occurrenceIndex) break if (found === occurrenceIndex) break
} }
cur.setDate(cur.getDate() + 1) cur = addDays(cur, 1)
safety++ safety++
} }
occurrenceDate = cur occurrenceDate = cur
weekday = cur.getDay() weekday = cur.getDay()
} }
} else if (event.repeat === 'months' && occurrenceIndex >= 0) { } else if (event.repeat === 'months' && occurrenceIndex >= 0) {
const baseDate = new Date(event.startDate + 'T00:00:00') const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
const cur = new Date(baseDate) occurrenceDate = addMonths(baseDate, occurrenceIndex)
cur.setMonth(cur.getMonth() + occurrenceIndex)
occurrenceDate = cur
} }
} }
dialogMode.value = 'edit' dialogMode.value = 'edit'
@ -558,7 +556,7 @@ const formattedOccurrenceShort = computed(() => {
const ev = calendarStore.getEventById(editingEventId.value) const ev = calendarStore.getEventById(editingEventId.value)
if (ev?.startDate) { if (ev?.startDate) {
try { try {
return new Date(ev.startDate + 'T00:00:00') return fromLocalString(ev.startDate, DEFAULT_TZ)
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) .toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
.replace(/, /, ' ') .replace(/, /, ' ')
} catch { } catch {
@ -586,7 +584,7 @@ const headerDateShort = computed(() => {
const ev = calendarStore.getEventById(editingEventId.value) const ev = calendarStore.getEventById(editingEventId.value)
if (ev?.startDate) { if (ev?.startDate) {
try { try {
return new Date(ev.startDate + 'T00:00:00') return fromLocalString(ev.startDate, DEFAULT_TZ)
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) .toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
.replace(/, /, ' ') .replace(/, /, ' ')
} catch { } catch {
@ -604,7 +602,7 @@ const finalOccurrenceDate = computed(() => {
// Need start date // Need start date
const base = editingEventId.value ? calendarStore.getEventById(editingEventId.value) : null const base = editingEventId.value ? calendarStore.getEventById(editingEventId.value) : null
if (!base) return null if (!base) return null
const start = new Date(base.startDate + 'T00:00:00') const start = fromLocalString(base.startDate, DEFAULT_TZ)
if (uiDisplayFrequency.value === 'weeks') { if (uiDisplayFrequency.value === 'weeks') {
// iterate days until we count 'count-1' additional occurrences (first is base if selected weekday) // iterate days until we count 'count-1' additional occurrences (first is base if selected weekday)
const pattern = buildStoreWeekdayPattern() // Sun..Sat const pattern = buildStoreWeekdayPattern() // Sun..Sat
@ -618,18 +616,16 @@ const finalOccurrenceDate = computed(() => {
// Convert to Monday-first index // Convert to Monday-first index
// We'll just check store pattern // We'll just check store pattern
if (pattern[startWeekdaySun]) occs = 1 if (pattern[startWeekdaySun]) occs = 1
let cursor = new Date(start) let cursor = new Date(start)
while (occs < count && occs < 10000) { while (occs < count && occs < 10000) {
cursor.setDate(cursor.getDate() + 1) cursor = addDays(cursor, 1)
if (pattern[cursor.getDay()]) occs++ if (pattern[cursor.getDay()]) occs++
} }
if (occs === count) return cursor if (occs === count) return cursor
return null return null
} else if (uiDisplayFrequency.value === 'months') { } else if (uiDisplayFrequency.value === 'months') {
const monthsToAdd = displayInterval.value * (count - 1) const monthsToAdd = displayInterval.value * (count - 1)
const d = new Date(start) return addMonths(start, monthsToAdd)
d.setMonth(d.getMonth() + monthsToAdd)
return d
} else if (uiDisplayFrequency.value === 'years') { } else if (uiDisplayFrequency.value === 'years') {
const yearsToAdd = displayInterval.value * (count - 1) const yearsToAdd = displayInterval.value * (count - 1)
const d = new Date(start) const d = new Date(start)

View File

@ -141,8 +141,9 @@ function resetAll() {
if (typeof calendarStore.$reset === 'function') { if (typeof calendarStore.$reset === 'function') {
calendarStore.$reset() calendarStore.$reset()
} else { } else {
calendarStore.today = new Date().toISOString().slice(0, 10) const now = new Date()
calendarStore.now = new Date().toISOString() calendarStore.today = now.toISOString().slice(0, 10)
calendarStore.now = now.toISOString()
calendarStore.events = new Map() calendarStore.events = new Map()
calendarStore.weekend = [6, 0] calendarStore.weekend = [6, 0]
calendarStore.config.first_day = 1 calendarStore.config.first_day = 1

View File

@ -16,7 +16,8 @@
<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 } from '@/utils/date' import { isoWeekInfo, toLocalString, getLocalizedMonthName, monthAbbr, DEFAULT_TZ } from '@/utils/date'
import { addDays } from 'date-fns'
const props = defineProps({ const props = defineProps({
week: { week: {
@ -33,7 +34,7 @@ const days = computed(() => {
const d = new Date(props.week.monday) const d = new Date(props.week.monday)
const result = [] const result = []
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(d) const dateStr = toLocalString(d, DEFAULT_TZ)
result.push({ result.push({
date: new Date(d), date: new Date(d),
dateStr, dateStr,
@ -42,7 +43,7 @@ const days = computed(() => {
isFirstDayOfMonth: d.getDate() === 1, isFirstDayOfMonth: d.getDate() === 1,
monthClass: monthAbbr[d.getMonth()] monthClass: monthAbbr[d.getMonth()]
}) })
d.setDate(d.getDate() + 1) d.setTime(addDays(d, 1).getTime())
} }
return result return result
}) })
@ -58,7 +59,7 @@ const monthLabel = computed(() => {
const weeksSpan = 4 const weeksSpan = 4
return { return {
name: getLocalizedMonthName(month), name: getLocalizedMonthName(month),
year: String(year).slice(-2), year: String(year).slice(-2),
weeksSpan weeksSpan
} }

View File

@ -5,7 +5,9 @@ import {
getLocaleWeekendDays, getLocaleWeekendDays,
getMondayOfISOWeek, getMondayOfISOWeek,
getOccurrenceIndex, getOccurrenceIndex,
DEFAULT_TZ,
} from '@/utils/date' } from '@/utils/date'
import { differenceInCalendarDays, addDays, addMonths } from 'date-fns'
import { import {
initializeHolidays, initializeHolidays,
getHolidayForDate, getHolidayForDate,
@ -19,7 +21,7 @@ const MAX_YEAR = 2100
export const useCalendarStore = defineStore('calendar', { export const useCalendarStore = defineStore('calendar', {
state: () => ({ state: () => ({
today: toLocalString(new Date()), today: toLocalString(new Date(), DEFAULT_TZ),
now: new Date().toISOString(), now: new Date().toISOString(),
events: new Map(), events: new Map(),
weekend: getLocaleWeekendDays(), weekend: getLocaleWeekendDays(),
@ -80,7 +82,7 @@ export const useCalendarStore = defineStore('calendar', {
updateCurrentDate() { updateCurrentDate() {
const d = new Date() const d = new Date()
this.now = d.toISOString() this.now = d.toISOString()
const today = toLocalString(d) const today = toLocalString(d, DEFAULT_TZ)
if (this.today !== today) { if (this.today !== today) {
this.today = today this.today = today
} }
@ -209,8 +211,8 @@ export const useCalendarStore = defineStore('calendar', {
selectEventColorId(startDateStr, endDateStr) { selectEventColorId(startDateStr, endDateStr) {
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
const startDate = new Date(fromLocalString(startDateStr)) const startDate = fromLocalString(startDateStr, DEFAULT_TZ)
const endDate = new Date(fromLocalString(endDateStr)) const endDate = fromLocalString(endDateStr, DEFAULT_TZ)
// Count events whose ranges overlap at least one day in selected span // Count events whose ranges overlap at least one day in selected span
for (const ev of this.events.values()) { for (const ev of this.events.values()) {
const evStart = fromLocalString(ev.startDate) const evStart = fromLocalString(ev.startDate)
@ -244,9 +246,9 @@ export const useCalendarStore = defineStore('calendar', {
if (base.repeat === 'weeks') { if (base.repeat === 'weeks') {
// Special case: deleting the first occurrence (index 0) should shift the series forward // Special case: deleting the first occurrence (index 0) should shift the series forward
if (occurrenceIndex === 0) { if (occurrenceIndex === 0) {
const baseStart = fromLocalString(base.startDate) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseEnd = fromLocalString(base.endDate) const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
const pattern = base.repeatWeekdays || [] const pattern = base.repeatWeekdays || []
if (!pattern.some(Boolean)) { if (!pattern.some(Boolean)) {
// No pattern to continue -> delete whole series // No pattern to continue -> delete whole series
@ -261,11 +263,11 @@ export const useCalendarStore = defineStore('calendar', {
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
return diff % interval === 0 return diff % interval === 0
} }
const probe = new Date(baseStart) let probe = new Date(baseStart)
let safety = 0 let safety = 0
let found = null let found = null
while (safety < 5000) { while (safety < 5000) {
probe.setDate(probe.getDate() + 1) probe = addDays(probe, 1)
if (pattern[probe.getDay()] && isAligned(probe)) { if (pattern[probe.getDay()] && isAligned(probe)) {
found = new Date(probe) found = new Date(probe)
break break
@ -289,10 +291,9 @@ export const useCalendarStore = defineStore('calendar', {
base.repeatCount = String(newRc) base.repeatCount = String(newRc)
} }
} }
const newEnd = new Date(found) const newEnd = addDays(found, spanDays)
newEnd.setDate(newEnd.getDate() + spanDays) base.startDate = toLocalString(found, DEFAULT_TZ)
base.startDate = toLocalString(found) base.endDate = toLocalString(newEnd, DEFAULT_TZ)
base.endDate = toLocalString(newEnd)
base.isSpanning = base.startDate < base.endDate base.isSpanning = base.startDate < base.endDate
this.events.set(base.id, base) this.events.set(base.id, base)
return return
@ -313,13 +314,13 @@ export const useCalendarStore = defineStore('calendar', {
) )
} else { } else {
// Fallback: derive from sequential occurrenceIndex (base=0, first repeat=1) // Fallback: derive from sequential occurrenceIndex (base=0, first repeat=1)
const baseStart = new Date(base.startDate + 'T00:00:00') const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseEnd = new Date(base.endDate + 'T00:00:00') const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
if (occurrenceIndex === 0) { if (occurrenceIndex === 0) {
targetDate = baseStart targetDate = baseStart
} else { } else {
let cur = new Date(baseEnd) let cur = new Date(baseEnd)
cur.setDate(cur.getDate() + 1) cur = addDays(cur, 1)
let found = 0 let found = 0
let safety = 0 let safety = 0
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
@ -334,7 +335,7 @@ export const useCalendarStore = defineStore('calendar', {
found++ found++
if (found === occurrenceIndex) break if (found === occurrenceIndex) break
} }
cur.setDate(cur.getDate() + 1) cur = addDays(cur, 1)
safety++ safety++
} }
targetDate = cur targetDate = cur
@ -343,7 +344,7 @@ export const useCalendarStore = defineStore('calendar', {
if (!targetDate) return if (!targetDate) return
// Count occurrences BEFORE target (always include the base occurrence as first) // Count occurrences BEFORE target (always include the base occurrence as first)
const baseStart = new Date(base.startDate + 'T00:00:00') const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseBlockStart = getMondayOfISOWeek(baseStart) const baseBlockStart = getMondayOfISOWeek(baseStart)
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
function isAligned(d) { function isAligned(d) {
@ -354,11 +355,11 @@ export const useCalendarStore = defineStore('calendar', {
// Start with 1 (base occurrence) if target is after base; if target IS base (should not happen here) leave 0 // Start with 1 (base occurrence) if target is after base; if target IS base (should not happen here) leave 0
let countBefore = targetDate > baseStart ? 1 : 0 let countBefore = targetDate > baseStart ? 1 : 0
let probe = new Date(baseStart) let probe = new Date(baseStart)
probe.setDate(probe.getDate() + 1) // start counting AFTER base probe = addDays(probe, 1) // start counting AFTER base
let safety2 = 0 let safety2 = 0
while (probe < targetDate && safety2 < 50000) { while (probe < targetDate && safety2 < 50000) {
if (pattern[probe.getDay()] && isAligned(probe)) countBefore++ if (pattern[probe.getDay()] && isAligned(probe)) countBefore++
probe.setDate(probe.getDate() + 1) probe = addDays(probe, 1)
safety2++ safety2++
} }
// Terminate original series to keep only occurrences before target // Terminate original series to keep only occurrences before target
@ -376,11 +377,11 @@ export const useCalendarStore = defineStore('calendar', {
} }
// Continuation starts at NEXT valid occurrence (matching weekday & aligned block) // Continuation starts at NEXT valid occurrence (matching weekday & aligned block)
let continuationStart = new Date(targetDate) let continuationStart = new Date(targetDate)
let searchSafety = 0 let searchSafety = 0
let foundNext = false let foundNext = false
while (searchSafety < 50000) { while (searchSafety < 50000) {
continuationStart.setDate(continuationStart.getDate() + 1) continuationStart = addDays(continuationStart, 1)
if (pattern[continuationStart.getDay()] && isAligned(continuationStart)) { if (pattern[continuationStart.getDay()] && isAligned(continuationStart)) {
foundNext = true foundNext = true
break break
@ -389,13 +390,13 @@ export const useCalendarStore = defineStore('calendar', {
} }
if (!foundNext) return // no remaining occurrences if (!foundNext) return // no remaining occurrences
const spanDays = Math.round( const spanDays = differenceInCalendarDays(
(fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000), fromLocalString(base.endDate, DEFAULT_TZ),
fromLocalString(base.startDate, DEFAULT_TZ),
) )
const nextStartStr = toLocalString(continuationStart) const nextStartStr = toLocalString(continuationStart, DEFAULT_TZ)
const nextEnd = new Date(continuationStart) const nextEnd = addDays(continuationStart, spanDays)
nextEnd.setDate(nextEnd.getDate() + spanDays) const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ)
const nextEndStr = toLocalString(nextEnd)
this.createEvent({ this.createEvent({
title: base.title, title: base.title,
startDate: nextStartStr, startDate: nextStartStr,
@ -411,9 +412,9 @@ export const useCalendarStore = defineStore('calendar', {
// MONTHLY SERIES ----------------------------------------------------- // MONTHLY SERIES -----------------------------------------------------
if (base.repeat === 'months') { if (base.repeat === 'months') {
if (occurrenceIndex === 0) { if (occurrenceIndex === 0) {
const baseStart = fromLocalString(base.startDate) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseEnd = fromLocalString(base.endDate) const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
const interval = base.repeatInterval || 1 const interval = base.repeatInterval || 1
const targetMonthIndex = baseStart.getMonth() + interval const targetMonthIndex = baseStart.getMonth() + interval
const targetYear = baseStart.getFullYear() + Math.floor(targetMonthIndex / 12) const targetYear = baseStart.getFullYear() + Math.floor(targetMonthIndex / 12)
@ -432,10 +433,9 @@ export const useCalendarStore = defineStore('calendar', {
base.repeatCount = String(newRc) base.repeatCount = String(newRc)
} }
} }
const newEnd = new Date(newStart) const newEnd = addDays(newStart, spanDays)
newEnd.setDate(newEnd.getDate() + spanDays) base.startDate = toLocalString(newStart, DEFAULT_TZ)
base.startDate = toLocalString(newStart) base.endDate = toLocalString(newEnd, DEFAULT_TZ)
base.endDate = toLocalString(newEnd)
base.isSpanning = base.startDate < base.endDate base.isSpanning = base.startDate < base.endDate
this.events.set(base.id, base) this.events.set(base.id, base)
return return
@ -448,8 +448,9 @@ export const useCalendarStore = defineStore('calendar', {
const priorOccurrences = occurrenceIndex const priorOccurrences = occurrenceIndex
this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences) this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences)
// Compute span days for multiday events // Compute span days for multiday events
const spanDays = Math.round( const spanDays = differenceInCalendarDays(
(fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000), fromLocalString(base.endDate, DEFAULT_TZ),
fromLocalString(base.startDate, DEFAULT_TZ),
) )
// Remaining occurrences after deletion // Remaining occurrences after deletion
let remainingCount = 'unlimited' let remainingCount = 'unlimited'
@ -462,13 +463,11 @@ export const useCalendarStore = defineStore('calendar', {
} }
} }
// Next occurrence after deleted one is at (occurrenceIndex + 1)*interval months from base // Next occurrence after deleted one is at (occurrenceIndex + 1)*interval months from base
const baseStart = fromLocalString(base.startDate) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const nextStart = new Date(baseStart) const nextStart = addMonths(baseStart, (occurrenceIndex + 1) * interval)
nextStart.setMonth(nextStart.getMonth() + (occurrenceIndex + 1) * interval) const nextEnd = addDays(nextStart, spanDays)
const nextEnd = new Date(nextStart) const nextStartStr = toLocalString(nextStart, DEFAULT_TZ)
nextEnd.setDate(nextEnd.getDate() + spanDays) const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ)
const nextStartStr = toLocalString(nextStart)
const nextEndStr = toLocalString(nextEnd)
this.createEvent({ this.createEvent({
title: base.title, title: base.title,
startDate: nextStartStr, startDate: nextStartStr,
@ -498,9 +497,9 @@ export const useCalendarStore = defineStore('calendar', {
deleteFirstOccurrence(baseId) { deleteFirstOccurrence(baseId) {
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base || !base.isRepeating) return if (!base || !base.isRepeating) return
const oldStart = fromLocalString(base.startDate) const oldStart = fromLocalString(base.startDate, DEFAULT_TZ)
const oldEnd = fromLocalString(base.endDate) const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const spanDays = Math.max(0, Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000))) const spanDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart))
let newStartDate = null let newStartDate = null
@ -520,10 +519,10 @@ export const useCalendarStore = defineStore('calendar', {
return diff % interval === 0 return diff % interval === 0
} }
// search forward for next valid weekday respecting interval alignment // search forward for next valid weekday respecting interval alignment
const probe = new Date(oldStart) let probe = new Date(oldStart)
let safety = 0 let safety = 0
while (safety < 5000) { while (safety < 5000) {
probe.setDate(probe.getDate() + 1) probe = addDays(probe, 1)
if (pattern[probe.getDay()] && isAligned(probe)) { if (pattern[probe.getDay()] && isAligned(probe)) {
newStartDate = new Date(probe) newStartDate = new Date(probe)
break break
@ -566,10 +565,9 @@ export const useCalendarStore = defineStore('calendar', {
} }
} }
const newEndDate = new Date(newStartDate) const newEndDate = addDays(newStartDate, spanDays)
newEndDate.setDate(newEndDate.getDate() + spanDays) base.startDate = toLocalString(newStartDate, DEFAULT_TZ)
base.startDate = toLocalString(newStartDate) base.endDate = toLocalString(newEndDate, DEFAULT_TZ)
base.endDate = toLocalString(newEndDate)
base.isSpanning = base.startDate < base.endDate base.isSpanning = base.startDate < base.endDate
// Persist updated base event // Persist updated base event
this.events.set(base.id, base) this.events.set(base.id, base)
@ -581,19 +579,13 @@ export const useCalendarStore = defineStore('calendar', {
const snapshot = this.events.get(eventId) const snapshot = this.events.get(eventId)
if (!snapshot) return if (!snapshot) return
// Calculate current duration in days (inclusive) // Calculate current duration in days (inclusive)
const prevStart = new Date(fromLocalString(snapshot.startDate)) const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ)
const prevEnd = new Date(fromLocalString(snapshot.endDate)) const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ)
const prevDurationDays = Math.max( const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart))
0,
Math.round((prevEnd - prevStart) / (24 * 60 * 60 * 1000)),
)
const newStart = new Date(fromLocalString(newStartStr)) const newStart = fromLocalString(newStartStr, DEFAULT_TZ)
const newEnd = new Date(fromLocalString(newEndStr)) const newEnd = fromLocalString(newEndStr, DEFAULT_TZ)
const proposedDurationDays = Math.max( const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart))
0,
Math.round((newEnd - newStart) / (24 * 60 * 60 * 1000)),
)
let finalDurationDays = prevDurationDays let finalDurationDays = prevDurationDays
if (mode === 'resize-left' || mode === 'resize-right') { if (mode === 'resize-left' || mode === 'resize-right') {
@ -601,13 +593,7 @@ export const useCalendarStore = defineStore('calendar', {
} }
snapshot.startDate = newStartStr snapshot.startDate = newStartStr
snapshot.endDate = toLocalString( snapshot.endDate = toLocalString(addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays), DEFAULT_TZ)
new Date(
new Date(fromLocalString(newStartStr)).setDate(
new Date(fromLocalString(newStartStr)).getDate() + finalDurationDays,
),
),
)
// 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' &&
@ -615,8 +601,8 @@ export const useCalendarStore = defineStore('calendar', {
snapshot.repeat === 'weeks' && snapshot.repeat === 'weeks' &&
Array.isArray(snapshot.repeatWeekdays) Array.isArray(snapshot.repeatWeekdays)
) { ) {
const oldDow = prevStart.getDay() const oldDow = prevStart.getDay()
const newDow = newStart.getDay() const newDow = newStart.getDay()
const shift = newDow - oldDow const shift = newDow - oldDow
if (shift !== 0) { if (shift !== 0) {
const rotated = [false, false, false, false, false, false, false] const rotated = [false, false, false, false, false, false, false]
@ -644,14 +630,9 @@ export const useCalendarStore = defineStore('calendar', {
const base = this.events.get(baseId) const base = this.events.get(baseId)
if (!base || !base.isRepeating) return if (!base || !base.isRepeating) return
const originalCountRaw = base.repeatCount const originalCountRaw = base.repeatCount
const spanDays = Math.max( // spanDays not needed for splitting logic here post-refactor
0, const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
Math.round( const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
(fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000),
),
)
const occurrenceDate = fromLocalString(occurrenceDateStr)
const baseStart = fromLocalString(base.startDate)
if (occurrenceDate <= baseStart) { if (occurrenceDate <= baseStart) {
// Moving the base itself: just move entire series // Moving the base itself: just move entire series
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' }) this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' })
@ -669,10 +650,10 @@ export const useCalendarStore = defineStore('calendar', {
const diff = Math.floor((blk - blockStartBase) / WEEK_MS) const diff = Math.floor((blk - blockStartBase) / WEEK_MS)
return diff % interval === 0 return diff % interval === 0
} }
const cursor = new Date(baseStart) let cursor = new Date(baseStart)
while (cursor < occurrenceDate) { while (cursor < occurrenceDate) {
if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++ if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++
cursor.setDate(cursor.getDate() + 1) cursor = addDays(cursor, 1)
} }
} else if (base.repeat === 'months') { } else if (base.repeat === 'months') {
const diffMonths = const diffMonths =
@ -702,7 +683,7 @@ export const useCalendarStore = defineStore('calendar', {
if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) { if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) {
// Rotate pattern so that the moved occurrence weekday stays active relative to new anchor // Rotate pattern so that the moved occurrence weekday stays active relative to new anchor
const origWeekday = occurrenceDate.getDay() const origWeekday = occurrenceDate.getDay()
const newWeekday = fromLocalString(newStartStr).getDay() const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay()
const shift = newWeekday - origWeekday const shift = newWeekday - origWeekday
if (shift !== 0) { if (shift !== 0) {
const rotated = [false, false, false, false, false, false, false] const rotated = [false, false, false, false, false, false, false]

View File

@ -1,18 +1,38 @@
// date-utils.js — Date handling utilities for the calendar // date-utils.js — Date handling utilities for the calendar (refactored to use date-fns/date-fns-tz)
const monthAbbr = [ import {
'jan', addDays,
'feb', differenceInCalendarDays,
'mar', differenceInCalendarMonths,
'apr', format,
'may', getDate,
'jun', getDay,
'jul', getDaysInMonth,
'aug', getMonth,
'sep', getYear,
'oct', isAfter,
'nov', isBefore,
'dec', isEqual,
] parseISO,
startOfDay,
} from 'date-fns'
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'
// Helper to create a zoned date (keeps wall-clock components in provided TZ)
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')}`
// Interpret as start of day in target zone
const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone)
return toZonedTime(utcDate, timeZone)
}
const monthAbbr = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
const DAY_MS = 86400000 const DAY_MS = 86400000
const WEEK_MS = 7 * DAY_MS const WEEK_MS = 7 * DAY_MS
@ -22,7 +42,8 @@ const WEEK_MS = 7 * DAY_MS
* @returns {Object} Object containing week number and year * @returns {Object} Object containing week number and year
*/ */
const isoWeekInfo = (date) => { const isoWeekInfo = (date) => {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) // ISO week: Thursday algorithm
const d = new Date(Date.UTC(getYear(date), getMonth(date), getDate(date)))
const day = d.getUTCDay() || 7 const day = d.getUTCDay() || 7
d.setUTCDate(d.getUTCDate() + 4 - day) d.setUTCDate(d.getUTCDate() + 4 - day)
const year = d.getUTCFullYear() const year = d.getUTCFullYear()
@ -36,9 +57,8 @@ const isoWeekInfo = (date) => {
* @param {Date} date - The date to convert (defaults to new Date()) * @param {Date} date - The date to convert (defaults to new Date())
* @returns {string} Date string in YYYY-MM-DD format * @returns {string} Date string in YYYY-MM-DD format
*/ */
function toLocalString(date = new Date()) { function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) {
const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0') return format(toZonedTime(date, timeZone), 'yyyy-MM-dd')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
} }
/** /**
@ -46,9 +66,10 @@ function toLocalString(date = new Date()) {
* @param {string} dateString - Date string in YYYY-MM-DD format * @param {string} dateString - Date string in YYYY-MM-DD format
* @returns {Date} Date object * @returns {Date} Date object
*/ */
function fromLocalString(dateString) { function fromLocalString(dateString, timeZone = DEFAULT_TZ) {
const [year, month, day] = dateString.split('-').map(Number) const parsed = parseISO(dateString)
return new Date(year, month - 1, day) const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone)
return toZonedTime(utcDate, timeZone) || parsed
} }
/** /**
@ -56,11 +77,10 @@ function fromLocalString(dateString) {
* @param {Date} date - The date to get the Monday for * @param {Date} date - The date to get the Monday for
* @returns {Date} Date object representing the Monday of the ISO week * @returns {Date} Date object representing the Monday of the ISO week
*/ */
function getMondayOfISOWeek(date) { function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) {
const d = new Date(date) const d = startOfDay(toZonedTime(date, timeZone))
const dayOfWeek = (d.getDay() + 6) % 7 // Convert to Monday=0, Sunday=6 const dayOfWeek = (getDay(d) + 6) % 7
d.setDate(d.getDate() - dayOfWeek) return addDays(d, -dayOfWeek)
return d
} }
/** /**
@ -68,7 +88,7 @@ function getMondayOfISOWeek(date) {
* @param {Date} d - The date * @param {Date} d - The date
* @returns {number} Monday index (0-6) * @returns {number} Monday index (0-6)
*/ */
const mondayIndex = (d) => (d.getDay() + 6) % 7 const mondayIndex = (d) => (getDay(d) + 6) % 7
/** /**
* Calculate the occurrence index for a repeating weekly event on a specific date * Calculate the occurrence index for a repeating weekly event on a specific date
@ -76,29 +96,29 @@ const mondayIndex = (d) => (d.getDay() + 6) % 7
* @param {string} dateStr - The date string (YYYY-MM-DD) to check * @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 * @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not valid
*/ */
function getWeeklyOccurrenceIndex(event, dateStr) { 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) const d = fromLocalString(dateStr, timeZone)
const dow = d.getDay() const dow = getDay(d)
if (!pattern[dow]) return null if (!pattern[dow]) return null
const baseStart = fromLocalString(event.startDate) const baseStart = fromLocalString(event.startDate, timeZone)
const interval = event.repeatInterval || 1 const interval = event.repeatInterval || 1
// Check if date resides in a week block that aligns with interval // Check if date resides in a week block that aligns with interval
const baseBlockStart = getMondayOfISOWeek(baseStart) const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
const currentBlockStart = getMondayOfISOWeek(d) const currentBlockStart = getMondayOfISOWeek(d, timeZone)
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
const blocksDiff = Math.floor((currentBlockStart - baseBlockStart) / WEEK_MS) const blocksDiff = Math.floor((currentBlockStart - baseBlockStart) / WEEK_MS)
if (blocksDiff < 0 || blocksDiff % interval !== 0) return null if (blocksDiff < 0 || blocksDiff % interval !== 0) return null
// For same week as base start, count from base start to target // For same week as base start, count from base start to target
if (currentBlockStart.getTime() === baseBlockStart.getTime()) { if (isEqual(currentBlockStart, baseBlockStart)) {
// Special handling for the first week - only count occurrences on or after base date // Special handling for the first week - only count occurrences on or after base date
if (d.getTime() === baseStart.getTime()) { if (d.getTime() === baseStart.getTime()) {
return 0 // Base occurrence is always index 0 return 0 // Base occurrence is always index 0
@ -108,18 +128,18 @@ function getWeeklyOccurrenceIndex(event, dateStr) {
return null // Dates before base start in same week are not valid occurrences return null // Dates before base start in same week are not valid occurrences
} }
let occurrenceIndex = 0 let occurrenceIndex = 0
const cursor = new Date(baseStart) let cursor = new Date(baseStart)
// Count the base occurrence first // Count the base occurrence first
if (pattern[cursor.getDay()]) occurrenceIndex++ if (pattern[getDay(cursor)]) occurrenceIndex++
// Move to the next day and count until we reach the target // Move to the next day and count until we reach the target
cursor.setDate(cursor.getDate() + 1) cursor = addDays(cursor, 1)
while (cursor <= d) { while (cursor <= d) {
if (pattern[cursor.getDay()]) occurrenceIndex++ if (pattern[getDay(cursor)]) occurrenceIndex++
cursor.setDate(cursor.getDate() + 1) cursor = addDays(cursor, 1)
} }
// Subtract 1 because we want the index, not the count // Subtract 1 because we want the index, not the count
occurrenceIndex-- occurrenceIndex--
@ -136,13 +156,12 @@ function getWeeklyOccurrenceIndex(event, dateStr) {
// For different weeks, calculate based on complete intervals // For different weeks, calculate based on complete intervals
// Calculate how many pattern days actually occur in the first week (from base start onward) // Calculate how many pattern days actually occur in the first week (from base start onward)
let firstWeekPatternDays = 0 let firstWeekPatternDays = 0
const firstWeekCursor = new Date(baseStart) let firstWeekCursor = new Date(baseStart)
const firstWeekEnd = new Date(baseBlockStart) const firstWeekEnd = addDays(new Date(baseBlockStart), 6) // End of first week (Sunday)
firstWeekEnd.setDate(firstWeekEnd.getDate() + 6) // End of first week (Sunday)
while (firstWeekCursor <= firstWeekEnd) { while (firstWeekCursor <= firstWeekEnd) {
if (pattern[firstWeekCursor.getDay()]) firstWeekPatternDays++ if (pattern[getDay(firstWeekCursor)]) firstWeekPatternDays++
firstWeekCursor.setDate(firstWeekCursor.getDate() + 1) firstWeekCursor = addDays(firstWeekCursor, 1)
} }
// For subsequent complete intervals, use the full pattern count // For subsequent complete intervals, use the full pattern count
@ -153,10 +172,10 @@ function getWeeklyOccurrenceIndex(event, dateStr) {
let occurrenceIndex = firstWeekPatternDays + (completeIntervals - 1) * fullWeekdaysPerInterval let occurrenceIndex = firstWeekPatternDays + (completeIntervals - 1) * fullWeekdaysPerInterval
// Add occurrences from the current week up to the target date // Add occurrences from the current week up to the target date
const cursor = new Date(currentBlockStart) cursor = new Date(currentBlockStart)
while (cursor < d) { while (cursor < d) {
if (pattern[cursor.getDay()]) occurrenceIndex++ if (pattern[getDay(cursor)]) occurrenceIndex++
cursor.setDate(cursor.getDate() + 1) cursor = addDays(cursor, 1)
} }
// Check against repeat count limit // Check against repeat count limit
@ -174,13 +193,12 @@ function getWeeklyOccurrenceIndex(event, dateStr) {
* @param {string} dateStr - The date string (YYYY-MM-DD) to check * @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 * @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not valid
*/ */
function getMonthlyOccurrenceIndex(event, dateStr) { 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) const baseStart = fromLocalString(event.startDate, timeZone)
const d = fromLocalString(dateStr) const d = fromLocalString(dateStr, timeZone)
const diffMonths = const diffMonths = differenceInCalendarMonths(d, baseStart)
(d.getFullYear() - baseStart.getFullYear()) * 12 + (d.getMonth() - baseStart.getMonth())
if (diffMonths < 0) return null if (diffMonths < 0) return null
@ -188,10 +206,10 @@ function getMonthlyOccurrenceIndex(event, dateStr) {
if (diffMonths % interval !== 0) return null if (diffMonths % interval !== 0) return null
// Check day match (clamped for shorter months) // Check day match (clamped for shorter months)
const baseDay = baseStart.getDate() const baseDay = getDate(baseStart)
const daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() const daysInMonth = getDaysInMonth(d)
const effectiveDay = Math.min(baseDay, daysInMonth) const effectiveDay = Math.min(baseDay, daysInMonth)
if (d.getDate() !== effectiveDay) return null if (getDate(d) !== effectiveDay) return null
const occurrenceIndex = diffMonths / interval const occurrenceIndex = diffMonths / interval
@ -210,14 +228,14 @@ function getMonthlyOccurrenceIndex(event, dateStr) {
* @param {string} dateStr - The date string (YYYY-MM-DD) to check * @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 * @returns {number|null} The occurrence index (0=base, 1=first repeat) or null if not occurring
*/ */
function getOccurrenceIndex(event, dateStr) { function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event || !event.isRepeating || event.repeat === 'none') return null if (!event || !event.isRepeating || event.repeat === 'none') return null
if (dateStr < event.startDate) return null if (dateStr < event.startDate) return null
if (event.repeat === 'weeks') { if (event.repeat === 'weeks') {
return getWeeklyOccurrenceIndex(event, dateStr) return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
} else if (event.repeat === 'months') { } else if (event.repeat === 'months') {
return getMonthlyOccurrenceIndex(event, dateStr) return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
} }
return null return null
@ -229,16 +247,13 @@ function getOccurrenceIndex(event, dateStr) {
* @param {string} occurrenceStartDate - The start date of the occurrence (YYYY-MM-DD) * @param {string} occurrenceStartDate - The start date of the occurrence (YYYY-MM-DD)
* @returns {string} The end date of the occurrence (YYYY-MM-DD) * @returns {string} The end date of the occurrence (YYYY-MM-DD)
*/ */
function getVirtualOccurrenceEndDate(event, occurrenceStartDate) { function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
const baseStart = fromLocalString(event.startDate) const baseStart = fromLocalString(event.startDate, timeZone)
const baseEnd = fromLocalString(event.endDate) const baseEnd = fromLocalString(event.endDate, timeZone)
const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone)
const occurrenceStart = fromLocalString(occurrenceStartDate) const occurrenceEnd = addDays(occurrenceStart, spanDays)
const occurrenceEnd = new Date(occurrenceStart) return toLocalString(occurrenceEnd, timeZone)
occurrenceEnd.setDate(occurrenceEnd.getDate() + spanDays)
return toLocalString(occurrenceEnd)
} }
/** /**
@ -247,16 +262,16 @@ function getVirtualOccurrenceEndDate(event, occurrenceStartDate) {
* @param {string} dateStr - The date string (YYYY-MM-DD) to check * @param {string} dateStr - The date string (YYYY-MM-DD) to check
* @returns {boolean} True if the event occurs on or spans through the date * @returns {boolean} True if the event occurs on or spans through the date
*/ */
function occursOnOrSpansDate(event, dateStr) { function occursOnOrSpansDate(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event || !event.isRepeating || event.repeat === 'none') return false if (!event || !event.isRepeating || event.repeat === 'none') return false
// Check if this is the base event spanning naturally // Check if this is the base event spanning naturally
if (dateStr >= event.startDate && dateStr <= event.endDate) return true if (dateStr >= event.startDate && dateStr <= event.endDate) return true
// For virtual occurrences, we need to check if any occurrence spans through this date // For virtual occurrences, we need to check if any occurrence spans through this date
const baseStart = fromLocalString(event.startDate) const baseStart = fromLocalString(event.startDate, timeZone)
const baseEnd = fromLocalString(event.endDate) const baseEnd = fromLocalString(event.endDate, timeZone)
const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
if (spanDays === 0) { if (spanDays === 0) {
// Single day event - just check if it occurs on this date // Single day event - just check if it occurs on this date
@ -264,14 +279,14 @@ function occursOnOrSpansDate(event, dateStr) {
} }
// Multi-day event - check if any occurrence's span includes this date // Multi-day event - check if any occurrence's span includes this date
const targetDate = fromLocalString(dateStr) const targetDate = fromLocalString(dateStr, timeZone)
if (event.repeat === 'weeks') { if (event.repeat === 'weeks') {
const pattern = event.repeatWeekdays || [] const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return false if (!pattern.some(Boolean)) return false
const interval = event.repeatInterval || 1 const interval = event.repeatInterval || 1
const baseBlockStart = getMondayOfISOWeek(baseStart) const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
// Check a reasonable range of weeks around the target date // Check a reasonable range of weeks around the target date
@ -280,75 +295,69 @@ function occursOnOrSpansDate(event, dateStr) {
weekOffset <= Math.ceil(spanDays / 7) + 1; weekOffset <= Math.ceil(spanDays / 7) + 1;
weekOffset++ weekOffset++
) { ) {
const weekStart = new Date(baseBlockStart) const weekStart = addDays(baseBlockStart, weekOffset * 7)
weekStart.setDate(weekStart.getDate() + weekOffset * 7)
// Check if this week aligns with the interval // Check if this week aligns with the interval
const blocksDiff = Math.floor((weekStart - baseBlockStart) / WEEK_MS) const blocksDiff = Math.floor((weekStart - baseBlockStart) / WEEK_MS)
if (blocksDiff < 0 || blocksDiff % interval !== 0) continue if (blocksDiff < 0 || blocksDiff % interval !== 0) continue
// Check each day in this week // Check each day in this week
for (let day = 0; day < 7; day++) { for (let day = 0; day < 7; day++) {
const candidateStart = new Date(weekStart) const candidateStart = addDays(weekStart, day)
candidateStart.setDate(candidateStart.getDate() + day)
// Skip if before base start // Skip if before base start
if (candidateStart < baseStart) continue if (isBefore(candidateStart, baseStart)) continue
// Check if this day matches the pattern // Check if this day matches the pattern
if (!pattern[candidateStart.getDay()]) continue if (!pattern[getDay(candidateStart)]) continue
// Check repeat count limit // Check repeat count limit
const occIndex = getWeeklyOccurrenceIndex(event, toLocalString(candidateStart)) const occIndex = getWeeklyOccurrenceIndex(event, toLocalString(candidateStart, timeZone), timeZone)
if (occIndex === null) continue if (occIndex === null) continue
// Calculate end date for this occurrence // Calculate end date for this occurrence
const candidateEnd = new Date(candidateStart) const candidateEnd = addDays(candidateStart, spanDays)
candidateEnd.setDate(candidateEnd.getDate() + spanDays)
// Check if target date falls within this occurrence's span // Check if target date falls within this occurrence's span
if (targetDate >= candidateStart && targetDate <= candidateEnd) { if (!isBefore(targetDate, candidateStart) && !isAfter(targetDate, candidateEnd)) {
return true return true
} }
} }
} }
} else if (event.repeat === 'months') { } else if (event.repeat === 'months') {
const interval = event.repeatInterval || 1 const interval = event.repeatInterval || 1
const baseDay = baseStart.getDate() const baseDay = getDate(baseStart)
// Check a reasonable range of months around the target date // Check a reasonable range of months around the target date
const targetYear = targetDate.getFullYear() // targetYear & targetMonth not needed in refactored logic
const targetMonth = targetDate.getMonth() const baseYear = getYear(baseStart)
const baseYear = baseStart.getFullYear() const baseMonth = getMonth(baseStart)
const baseMonth = baseStart.getMonth()
for (let monthOffset = -spanDays * 2; monthOffset <= spanDays * 2; monthOffset++) { for (let monthOffset = -spanDays * 2; monthOffset <= spanDays * 2; monthOffset++) {
const candidateYear = baseYear + Math.floor((baseMonth + monthOffset) / 12) const candidateYear = baseYear + Math.floor((baseMonth + monthOffset) / 12)
const candidateMonth = (baseMonth + monthOffset + 12) % 12 const candidateMonth = (baseMonth + monthOffset + 12) % 12
// Check if this month aligns with the interval // Check if this month aligns with the interval
const diffMonths = (candidateYear - baseYear) * 12 + (candidateMonth - baseMonth) const diffMonths = (candidateYear - baseYear) * 12 + (candidateMonth - baseMonth)
if (diffMonths < 0 || diffMonths % interval !== 0) continue if (diffMonths < 0 || diffMonths % interval !== 0) continue
// Calculate the actual day (clamped for shorter months) // Calculate the actual day (clamped for shorter months)
const daysInMonth = new Date(candidateYear, candidateMonth + 1, 0).getDate() const daysInMonth = getDaysInMonth(new Date(candidateYear, candidateMonth, 1))
const effectiveDay = Math.min(baseDay, daysInMonth) const effectiveDay = Math.min(baseDay, daysInMonth)
const candidateStart = makeTZDate(candidateYear, candidateMonth, effectiveDay)
const candidateStart = new Date(candidateYear, candidateMonth, effectiveDay)
// Skip if before base start // Skip if before base start
if (candidateStart < baseStart) continue if (isBefore(candidateStart, baseStart)) continue
// Check repeat count limit // Check repeat count limit
const occIndex = getMonthlyOccurrenceIndex(event, toLocalString(candidateStart)) const occIndex = getMonthlyOccurrenceIndex(event, toLocalString(candidateStart, timeZone), timeZone)
if (occIndex === null) continue if (occIndex === null) continue
// Calculate end date for this occurrence // Calculate end date for this occurrence
const candidateEnd = new Date(candidateStart) const candidateEnd = addDays(candidateStart, spanDays)
candidateEnd.setDate(candidateEnd.getDate() + spanDays)
// Check if target date falls within this occurrence's span // Check if target date falls within this occurrence's span
if (targetDate >= candidateStart && targetDate <= candidateEnd) { if (!isBefore(targetDate, candidateStart) && !isAfter(targetDate, candidateEnd)) {
return true return true
} }
} }
@ -368,12 +377,10 @@ const pad = (n) => String(n).padStart(2, '0')
* @param {string} bStr - Second date string (YYYY-MM-DD) * @param {string} bStr - Second date string (YYYY-MM-DD)
* @returns {number} Number of days inclusive * @returns {number} Number of days inclusive
*/ */
function daysInclusive(aStr, bStr) { function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) {
const a = fromLocalString(aStr) const a = fromLocalString(aStr, timeZone)
const b = fromLocalString(bStr) const b = fromLocalString(bStr, timeZone)
const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime() return Math.abs(differenceInCalendarDays(startOfDay(a), startOfDay(b))) + 1
const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
return Math.floor(Math.abs(B - A) / DAY_MS) + 1
} }
/** /**
@ -382,23 +389,23 @@ function daysInclusive(aStr, bStr) {
* @param {number} n - Number of days to add (can be negative) * @param {number} n - Number of days to add (can be negative)
* @returns {string} New date string * @returns {string} New date string
*/ */
function addDaysStr(str, n) { function addDaysStr(str, n, timeZone = DEFAULT_TZ) {
const d = fromLocalString(str) const d = fromLocalString(str, timeZone)
d.setDate(d.getDate() + n) return toLocalString(addDays(d, n), timeZone)
return toLocalString(d)
} }
/** /**
* Get localized weekday names starting from Monday * Get localized weekday names starting from Monday
* @returns {Array<string>} Array of localized weekday names * @returns {Array<string>} Array of localized weekday names
*/ */
function getLocalizedWeekdayNames() { function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
const res = [] const res = []
const base = new Date(2025, 0, 6) // A Monday const base = makeTZDate(2025, 0, 6, timeZone) // Monday
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const d = new Date(base) const d = addDays(base, i)
d.setDate(base.getDate() + i) res.push(
res.push(d.toLocaleDateString(undefined, { weekday: 'short' })) new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format(d)
)
} }
return res return res
} }
@ -445,9 +452,9 @@ function reorderByFirstDay(days, firstDay) {
* @param {boolean} short - Whether to return short name * @param {boolean} short - Whether to return short name
* @returns {string} Localized month name * @returns {string} Localized month name
*/ */
function getLocalizedMonthName(idx, short = false) { function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) {
const d = new Date(2025, idx, 1) const d = makeTZDate(2025, idx, 1, timeZone)
return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' }) return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d)
} }
/** /**
@ -456,10 +463,11 @@ function getLocalizedMonthName(idx, short = false) {
* @param {Date} endDate - End date * @param {Date} endDate - End date
* @returns {string} Formatted date range string * @returns {string} Formatted date range string
*/ */
function formatDateRange(startDate, endDate) { function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) {
if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) if (toLocalString(startDate, timeZone) === toLocalString(endDate, timeZone))
const startISO = toLocalString(startDate) return toLocalString(startDate, timeZone)
const endISO = toLocalString(endDate) const startISO = toLocalString(startDate, timeZone)
const endISO = toLocalString(endDate, timeZone)
const [sy, sm] = startISO.split('-') const [sy, sm] = startISO.split('-')
const [ey, em, ed] = endISO.split('-') const [ey, em, ed] = endISO.split('-')
if (sy === ey && sm === em) return `${startISO}/${ed}` if (sy === ey && sm === em) return `${startISO}/${ed}`
@ -521,4 +529,6 @@ export {
getLocalizedMonthName, getLocalizedMonthName,
formatDateRange, formatDateRange,
lunarPhaseSymbol, lunarPhaseSymbol,
makeTZDate,
DEFAULT_TZ,
} }