diff --git a/package.json b/package.json index 23b62da..7e0f7b7 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ }, "dependencies": { "date-holidays": "^3.25.1", + "date-fns": "^3.6.0", + "date-fns-tz": "^3.0.0", "pinia": "^3.0.3", "pinia-plugin-persistedstate": "^4.5.0", "vue": "^3.5.18" diff --git a/src/components/CalendarGrid.vue b/src/components/CalendarGrid.vue index d9adbd8..563c009 100644 --- a/src/components/CalendarGrid.vue +++ b/src/components/CalendarGrid.vue @@ -29,7 +29,9 @@ import { fromLocalString, toLocalString, mondayIndex, + DEFAULT_TZ, } from '@/utils/date' +import { addDays } from 'date-fns' import WeekRow from './WeekRow.vue' const calendarStore = useCalendarStore() @@ -45,6 +47,7 @@ const config = { weekend: getLocaleWeekendDays(), } +// Anchor Monday (or locale first day) reference date const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay()) const WEEK_MS = 7 * 86400000 @@ -56,16 +59,11 @@ const isWeekend = (day) => { } const getWeekIndex = (date) => { - const monday = new Date(date) - monday.setDate(date.getDate() - mondayIndex(date)) - return Math.floor((monday - baseDate) / WEEK_MS) + const monday = addDays(date, -mondayIndex(date)) + return Math.floor((monday.getTime() - baseDate.getTime()) / WEEK_MS) } -const getMondayForVirtualWeek = (virtualWeek) => { - const monday = new Date(baseDate) - monday.setDate(monday.getDate() + virtualWeek * 7) - return monday -} +const getMondayForVirtualWeek = (virtualWeek) => addDays(baseDate, virtualWeek * 7) const computeRowHeight = () => { const el = document.createElement('div') @@ -104,10 +102,7 @@ const updateVisibleWeeks = () => { const newVisibleWeeks = [] for (let vw = startVW; vw <= endVW; vw++) { - newVisibleWeeks.push({ - virtualWeek: vw, - monday: getMondayForVirtualWeek(vw), - }) + newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) }) } visibleWeeks.value = newVisibleWeeks } @@ -133,10 +128,8 @@ const navigateToYear = (targetYear, weekIndex) => { const monday = getMondayForVirtualWeek(weekIndex) const { week } = isoWeekInfo(monday) const jan4 = new Date(targetYear, 0, 4) - const jan4Monday = new Date(jan4) - jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4)) - const targetMonday = new Date(jan4Monday) - targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7) + const jan4Monday = addDays(jan4, -mondayIndex(jan4)) + const targetMonday = addDays(jan4Monday, (week - 1) * 7) scrollToTarget(targetMonday) } @@ -155,8 +148,7 @@ const scrollToTarget = (target) => { const goToTodayHandler = () => { const today = new Date() - const top = new Date(today) - top.setDate(top.getDate() - 21) + const top = addDays(today, -21) scrollToTarget(top) } @@ -165,14 +157,13 @@ onMounted(() => { const minYearDate = new Date(config.min_year, 0, 1) const maxYearLastDay = new Date(config.max_year, 11, 31) - const lastWeekMonday = new Date(maxYearLastDay) - lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay)) + const lastWeekMonday = addDays(maxYearLastDay, -mondayIndex(maxYearLastDay)) minVirtualWeek.value = getWeekIndex(minYearDate) const maxVirtualWeek = getWeekIndex(lastWeekMonday) totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1 - const initialDate = fromLocalString(calendarStore.today) + const initialDate = fromLocalString(calendarStore.today, DEFAULT_TZ) scrollToTarget(initialDate) document.addEventListener('goToToday', goToTodayHandler) diff --git a/src/components/CalendarHeader.vue b/src/components/CalendarHeader.vue index b3fde2b..0b20636 100644 --- a/src/components/CalendarHeader.vue +++ b/src/components/CalendarHeader.vue @@ -3,6 +3,7 @@ import { computed } from 'vue' import { useCalendarStore } from '@/stores/CalendarStore' import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } from '@/utils/date' import Numeric from '@/components/Numeric.vue' +import { addDays } from 'date-fns' const props = defineProps({ scrollTop: { type: Number, default: 0 }, @@ -24,28 +25,21 @@ const topVirtualWeek = computed(() => { }) const currentYear = computed(() => { - const weekStart = new Date(baseDate.value) - weekStart.setDate(weekStart.getDate() + topVirtualWeek.value * 7) - // ISO anchor Thursday - const anchor = new Date(weekStart) - anchor.setDate(anchor.getDate() + ((4 - anchor.getDay() + 7) % 7)) + const weekStart = addDays(baseDate.value, topVirtualWeek.value * 7) + const anchor = addDays(weekStart, (4 - weekStart.getDay() + 7) % 7) return isoWeekInfo(anchor).year }) function virtualWeekOf(d) { const o = (d.getDay() - calendarStore.config.first_day + 7) % 7 - const fd = new Date(d) - fd.setDate(d.getDate() - o) - return Math.floor((fd - baseDate.value) / WEEK_MS) + const fd = addDays(d, -o) + return Math.floor((fd.getTime() - baseDate.value.getTime()) / WEEK_MS) } function isoWeekMonday(isoYear, isoWeek) { const jan4 = new Date(isoYear, 0, 4) - const week1Mon = new Date(jan4) - week1Mon.setDate(week1Mon.getDate() - ((week1Mon.getDay() + 6) % 7)) - const target = new Date(week1Mon) - target.setDate(target.getDate() + (isoWeek - 1) * 7) - return target + const week1Mon = addDays(jan4, -((jan4.getDay() + 6) % 7)) + return addDays(week1Mon, (isoWeek - 1) * 7) } function changeYear(y) { @@ -57,10 +51,8 @@ function changeYear(y) { const weekStartScroll = (vw - props.minVirtualWeek) * props.rowHeight const frac = Math.max(0, Math.min(1, (props.scrollTop - weekStartScroll) / props.rowHeight)) // Anchor Thursday of current calendar week - const curCalWeekStart = new Date(baseDate.value) - curCalWeekStart.setDate(curCalWeekStart.getDate() + vw * 7) - const curAnchorThu = new Date(curCalWeekStart) - curAnchorThu.setDate(curAnchorThu.getDate() + ((4 - curAnchorThu.getDay() + 7) % 7)) + const curCalWeekStart = addDays(baseDate.value, vw * 7) + const curAnchorThu = addDays(curCalWeekStart, (4 - curCalWeekStart.getDay() + 7) % 7) let { week: isoW } = isoWeekInfo(curAnchorThu) // Build Monday of ISO week let weekMon = isoWeekMonday(y, isoW) @@ -70,8 +62,7 @@ function changeYear(y) { } // Align to configured first day const shift = (weekMon.getDay() - calendarStore.config.first_day + 7) % 7 - const calWeekStart = new Date(weekMon) - calWeekStart.setDate(calWeekStart.getDate() - shift) + const calWeekStart = addDays(weekMon, -shift) const targetVW = virtualWeekOf(calWeekStart) let scrollTop = (targetVW - props.minVirtualWeek) * props.rowHeight + frac * props.rowHeight if (Math.abs(scrollTop - props.scrollTop) < 0.5) scrollTop += 0.25 * props.rowHeight diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index 905b796..aa1eb90 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -18,7 +18,8 @@ import { getOccurrenceIndex, getVirtualOccurrenceEndDate, } 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 viewport = ref(null) @@ -49,18 +50,16 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000 const minVirtualWeek = computed(() => { const date = new Date(calendarStore.minYear, 0, 1) - const firstDayOfWeek = new Date(date) const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 - firstDayOfWeek.setDate(date.getDate() - dayOffset) - return Math.floor((firstDayOfWeek - baseDate.value) / WEEK_MS) + const firstDayOfWeek = addDays(date, -dayOffset) + return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS) }) const maxVirtualWeek = computed(() => { const date = new Date(calendarStore.maxYear, 11, 31) - const firstDayOfWeek = new Date(date) const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 - firstDayOfWeek.setDate(date.getDate() - dayOffset) - return Math.floor((firstDayOfWeek - baseDate.value) / WEEK_MS) + const firstDayOfWeek = addDays(date, -dayOffset) + return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS) }) const totalVirtualWeeks = computed(() => { @@ -123,25 +122,21 @@ function computeRowHeight() { } function getWeekIndex(date) { - const firstDayOfWeek = new Date(date) const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 - firstDayOfWeek.setDate(date.getDate() - dayOffset) - return Math.floor((firstDayOfWeek - baseDate.value) / WEEK_MS) + const firstDayOfWeek = addDays(date, -dayOffset) + return Math.floor((firstDayOfWeek.getTime() - baseDate.value.getTime()) / WEEK_MS) } function getFirstDayForVirtualWeek(virtualWeek) { - const firstDay = new Date(baseDate.value) - firstDay.setDate(firstDay.getDate() + virtualWeek * 7) - return firstDay + return addDays(baseDate.value, virtualWeek * 7) } function createWeek(virtualWeek) { const firstDay = getFirstDayForVirtualWeek(virtualWeek) - const isoAnchor = new Date(firstDay) - isoAnchor.setDate(isoAnchor.getDate() + ((4 - isoAnchor.getDay() + 7) % 7)) + const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7) const weekNumber = isoWeekInfo(isoAnchor).week const days = [] - const cur = new Date(firstDay) + let cur = new Date(firstDay) let hasFirst = false let monthToLabel = null let labelYear = null @@ -154,7 +149,7 @@ function createWeek(virtualWeek) { } for (let i = 0; i < 7; i++) { - const dateStr = toLocalString(cur) + const dateStr = toLocalString(cur, DEFAULT_TZ) const storedEvents = [] // 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 - const baseStart = fromLocalString(base.startDate) - const baseEnd = fromLocalString(base.endDate) - const spanDays = Math.max(0, Math.round((baseEnd - baseStart) / (24 * 60 * 60 * 1000))) - const currentDate = fromLocalString(dateStr) + const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) + const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ) + const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart)) + const currentDate = fromLocalString(dateStr, DEFAULT_TZ) let occurrenceFound = false // Walk backwards within span to find occurrence start for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) { - const candidateStart = new Date(currentDate) - candidateStart.setDate(candidateStart.getDate() - offset) - const candidateStartStr = toLocalString(candidateStart) + const candidateStart = addDays(currentDate, -offset) + const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ) - const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr) + const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ) if (occurrenceIndex !== null) { // 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 if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) { @@ -258,18 +252,18 @@ function createWeek(virtualWeek) { dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1), events: dayEvents, }) - cur.setDate(cur.getDate() + 1) + cur = addDays(cur, 1) } let monthLabel = null if (hasFirst && monthToLabel !== null) { if (labelYear && labelYear <= calendarStore.config.max_year) { let weeksSpan = 0 - const d = new Date(cur) - d.setDate(cur.getDate() - 1) + const d = addDays(cur, -1) 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++ } @@ -296,8 +290,7 @@ function createWeek(virtualWeek) { } function goToToday() { - const top = new Date(calendarStore.now) - top.setDate(top.getDate() - 21) + const top = addDays(new Date(calendarStore.now), -21) const targetWeekIndex = getWeekIndex(top) scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value if (viewport.value) { @@ -331,8 +324,8 @@ function endDrag(dateStr) { function calculateSelection(anchorStr, otherStr) { const limit = calendarStore.config.select_days - const anchorDate = fromLocalString(anchorStr) - const otherDate = fromLocalString(otherStr) + const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ) + const otherDate = fromLocalString(otherStr, DEFAULT_TZ) const forward = otherDate >= anchorDate const span = daysInclusive(anchorStr, otherStr) @@ -344,7 +337,7 @@ function calculateSelection(anchorStr, otherStr) { if (forward) { return { startDate: anchorStr, dayCount: limit } } else { - const startDate = addDaysStr(anchorStr, -(limit - 1)) + const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ) return { startDate, dayCount: limit } } } @@ -448,7 +441,7 @@ watch( () => calendarStore.config.first_day, () => { const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value - const currentTopDate = getFirstDayForVirtualWeek(currentTopVW) + const currentTopDate = getFirstDayForVirtualWeek(currentTopVW) requestAnimationFrame(() => { const newTopWeekIndex = getWeekIndex(currentTopDate) const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value diff --git a/src/components/EventDialog.vue b/src/components/EventDialog.vue index 95b3d5a..7d2c28c 100644 --- a/src/components/EventDialog.vue +++ b/src/components/EventDialog.vue @@ -4,7 +4,8 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import BaseDialog from './BaseDialog.vue' import WeekdaySelector from './WeekdaySelector.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({ selection: { type: Object, default: () => ({ startDate: null, dayCount: 0 }) }, @@ -97,7 +98,7 @@ const modalStyle = computed(() => { function getStartingWeekday(selectionData = null) { const currentSelection = selectionData || props.selection 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, ... 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 if (event.isRepeating) { if (event.repeat === 'weeks' && occurrenceIndex >= 0) { - const pattern = event.repeatWeekdays || [] - const baseStart = new Date(event.startDate + 'T00:00:00') - const baseEnd = new Date(event.endDate + 'T00:00:00') + const pattern = event.repeatWeekdays || [] + const baseStart = fromLocalString(event.startDate, DEFAULT_TZ) + const baseEnd = fromLocalString(event.endDate, DEFAULT_TZ) if (occurrenceIndex === 0) { occurrenceDate = baseStart weekday = baseStart.getDay() @@ -333,8 +334,7 @@ function openEditDialog(payload) { const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) return diff % interval === 0 } - let cur = new Date(baseEnd) - cur.setDate(cur.getDate() + 1) + let cur = addDays(baseEnd, 1) let found = 0 // number of repeat occurrences found so far let safety = 0 while (found < occurrenceIndex && safety < 20000) { @@ -342,17 +342,15 @@ function openEditDialog(payload) { found++ if (found === occurrenceIndex) break } - cur.setDate(cur.getDate() + 1) + cur = addDays(cur, 1) safety++ } occurrenceDate = cur weekday = cur.getDay() } } else if (event.repeat === 'months' && occurrenceIndex >= 0) { - const baseDate = new Date(event.startDate + 'T00:00:00') - const cur = new Date(baseDate) - cur.setMonth(cur.getMonth() + occurrenceIndex) - occurrenceDate = cur + const baseDate = fromLocalString(event.startDate, DEFAULT_TZ) + occurrenceDate = addMonths(baseDate, occurrenceIndex) } } dialogMode.value = 'edit' @@ -558,7 +556,7 @@ const formattedOccurrenceShort = computed(() => { const ev = calendarStore.getEventById(editingEventId.value) if (ev?.startDate) { try { - return new Date(ev.startDate + 'T00:00:00') + return fromLocalString(ev.startDate, DEFAULT_TZ) .toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) .replace(/, /, ' ') } catch { @@ -586,7 +584,7 @@ const headerDateShort = computed(() => { const ev = calendarStore.getEventById(editingEventId.value) if (ev?.startDate) { try { - return new Date(ev.startDate + 'T00:00:00') + return fromLocalString(ev.startDate, DEFAULT_TZ) .toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) .replace(/, /, ' ') } catch { @@ -604,7 +602,7 @@ const finalOccurrenceDate = computed(() => { // Need start date const base = editingEventId.value ? calendarStore.getEventById(editingEventId.value) : 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') { // iterate days until we count 'count-1' additional occurrences (first is base if selected weekday) const pattern = buildStoreWeekdayPattern() // Sun..Sat @@ -618,18 +616,16 @@ const finalOccurrenceDate = computed(() => { // Convert to Monday-first index // We'll just check store pattern if (pattern[startWeekdaySun]) occs = 1 - let cursor = new Date(start) + let cursor = new Date(start) while (occs < count && occs < 10000) { - cursor.setDate(cursor.getDate() + 1) + cursor = addDays(cursor, 1) if (pattern[cursor.getDay()]) occs++ } if (occs === count) return cursor return null } else if (uiDisplayFrequency.value === 'months') { - const monthsToAdd = displayInterval.value * (count - 1) - const d = new Date(start) - d.setMonth(d.getMonth() + monthsToAdd) - return d + const monthsToAdd = displayInterval.value * (count - 1) + return addMonths(start, monthsToAdd) } else if (uiDisplayFrequency.value === 'years') { const yearsToAdd = displayInterval.value * (count - 1) const d = new Date(start) diff --git a/src/components/SettingsDialog.vue b/src/components/SettingsDialog.vue index 18ad681..9c23b2e 100644 --- a/src/components/SettingsDialog.vue +++ b/src/components/SettingsDialog.vue @@ -141,8 +141,9 @@ function resetAll() { if (typeof calendarStore.$reset === 'function') { calendarStore.$reset() } else { - calendarStore.today = new Date().toISOString().slice(0, 10) - calendarStore.now = new Date().toISOString() + const now = new Date() + calendarStore.today = now.toISOString().slice(0, 10) + calendarStore.now = now.toISOString() calendarStore.events = new Map() calendarStore.weekend = [6, 0] calendarStore.config.first_day = 1 diff --git a/src/components/WeekRow.vue b/src/components/WeekRow.vue index 65a49ad..a656043 100644 --- a/src/components/WeekRow.vue +++ b/src/components/WeekRow.vue @@ -16,7 +16,8 @@