Major new version #2
@ -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)
|
||||||
}
|
}
|
||||||
@ -102,7 +103,7 @@ const updateVisibleWeeks = () => {
|
|||||||
|
|
||||||
const newVisibleWeeks = []
|
const newVisibleWeeks = []
|
||||||
for (let vw = startVW; vw <= endVW; vw++) {
|
for (let vw = startVW; vw <= endVW; vw++) {
|
||||||
newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) })
|
newVisibleWeeks.push({ virtualWeek: vw, monday: getMondayForVirtualWeek(vw) })
|
||||||
}
|
}
|
||||||
visibleWeeks.value = newVisibleWeeks
|
visibleWeeks.value = newVisibleWeeks
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
@ -149,7 +149,7 @@ function createWeek(virtualWeek) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
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
|
||||||
@ -175,19 +175,19 @@ function createWeek(virtualWeek) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if any virtual occurrence spans this date
|
// Check if any virtual occurrence spans this date
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
||||||
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
|
||||||
const currentDate = fromLocalString(dateStr, DEFAULT_TZ)
|
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 = addDays(currentDate, -offset)
|
const candidateStart = addDays(currentDate, -offset)
|
||||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||||
|
|
||||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
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, DEFAULT_TZ)
|
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
||||||
@ -252,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 = addDays(cur, 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 = addDays(cur, -1)
|
const d = addDays(cur, -1)
|
||||||
|
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const probe = addDays(cur, -1 + i * 7)
|
const probe = addDays(cur, -1 + i * 7)
|
||||||
d.setTime(probe.getTime())
|
d.setTime(probe.getTime())
|
||||||
if (d.getMonth() === monthToLabel) weeksSpan++
|
if (d.getMonth() === monthToLabel) weeksSpan++
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,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), DEFAULT_TZ)
|
const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
|
||||||
return { startDate, dayCount: limit }
|
return { startDate, dayCount: limit }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -441,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
|
||||||
|
@ -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({
|
||||||
@ -318,9 +324,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 = fromLocalString(event.startDate, DEFAULT_TZ)
|
const baseStart = fromLocalString(event.startDate, DEFAULT_TZ)
|
||||||
const baseEnd = fromLocalString(event.endDate, DEFAULT_TZ)
|
const baseEnd = fromLocalString(event.endDate, DEFAULT_TZ)
|
||||||
if (occurrenceIndex === 0) {
|
if (occurrenceIndex === 0) {
|
||||||
occurrenceDate = baseStart
|
occurrenceDate = baseStart
|
||||||
weekday = baseStart.getDay()
|
weekday = baseStart.getDay()
|
||||||
@ -334,7 +340,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 = addDays(baseEnd, 1)
|
let cur = addDays(baseEnd, 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) {
|
||||||
@ -349,8 +355,8 @@ function openEditDialog(payload) {
|
|||||||
weekday = cur.getDay()
|
weekday = cur.getDay()
|
||||||
}
|
}
|
||||||
} else if (event.repeat === 'months' && occurrenceIndex >= 0) {
|
} else if (event.repeat === 'months' && occurrenceIndex >= 0) {
|
||||||
const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
|
const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
|
||||||
occurrenceDate = addMonths(baseDate, occurrenceIndex)
|
occurrenceDate = addMonths(baseDate, occurrenceIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
@ -556,7 +562,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 fromLocalString(ev.startDate, DEFAULT_TZ)
|
return fromLocalString(ev.startDate, DEFAULT_TZ)
|
||||||
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
.replace(/, /, ' ')
|
.replace(/, /, ' ')
|
||||||
} catch {
|
} catch {
|
||||||
@ -584,7 +590,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 fromLocalString(ev.startDate, DEFAULT_TZ)
|
return fromLocalString(ev.startDate, DEFAULT_TZ)
|
||||||
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
.replace(/, /, ' ')
|
.replace(/, /, ' ')
|
||||||
} catch {
|
} catch {
|
||||||
@ -616,16 +622,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 = addDays(cursor, 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)
|
||||||
return addMonths(start, monthsToAdd)
|
return addMonths(start, monthsToAdd)
|
||||||
} 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)
|
||||||
|
@ -141,9 +141,9 @@ function resetAll() {
|
|||||||
if (typeof calendarStore.$reset === 'function') {
|
if (typeof calendarStore.$reset === 'function') {
|
||||||
calendarStore.$reset()
|
calendarStore.$reset()
|
||||||
} else {
|
} else {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
calendarStore.today = now.toISOString().slice(0, 10)
|
calendarStore.today = now.toISOString().slice(0, 10)
|
||||||
calendarStore.now = now.toISOString()
|
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
|
||||||
|
@ -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
|
||||||
@ -59,9 +67,9 @@ 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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
@ -21,7 +21,7 @@ const MAX_YEAR = 2100
|
|||||||
|
|
||||||
export const useCalendarStore = defineStore('calendar', {
|
export const useCalendarStore = defineStore('calendar', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
today: toLocalString(new Date(), DEFAULT_TZ),
|
today: toLocalString(new Date(), DEFAULT_TZ),
|
||||||
now: new Date().toISOString(),
|
now: new Date().toISOString(),
|
||||||
events: new Map(),
|
events: new Map(),
|
||||||
weekend: getLocaleWeekendDays(),
|
weekend: getLocaleWeekendDays(),
|
||||||
@ -82,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, DEFAULT_TZ)
|
const today = toLocalString(d, DEFAULT_TZ)
|
||||||
if (this.today !== today) {
|
if (this.today !== today) {
|
||||||
this.today = today
|
this.today = today
|
||||||
}
|
}
|
||||||
@ -344,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 = fromLocalString(base.startDate, DEFAULT_TZ)
|
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) {
|
||||||
@ -355,7 +355,7 @@ 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 = addDays(probe, 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++
|
||||||
@ -377,7 +377,7 @@ 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) {
|
||||||
@ -463,11 +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, DEFAULT_TZ)
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
const nextStart = addMonths(baseStart, (occurrenceIndex + 1) * interval)
|
const nextStart = addMonths(baseStart, (occurrenceIndex + 1) * interval)
|
||||||
const nextEnd = addDays(nextStart, spanDays)
|
const nextEnd = addDays(nextStart, spanDays)
|
||||||
const nextStartStr = toLocalString(nextStart, DEFAULT_TZ)
|
const nextStartStr = toLocalString(nextStart, DEFAULT_TZ)
|
||||||
const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ)
|
const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ)
|
||||||
this.createEvent({
|
this.createEvent({
|
||||||
title: base.title,
|
title: base.title,
|
||||||
startDate: nextStartStr,
|
startDate: nextStartStr,
|
||||||
@ -497,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, DEFAULT_TZ)
|
const oldStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ)
|
||||||
const spanDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart))
|
const spanDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart))
|
||||||
|
|
||||||
let newStartDate = null
|
let newStartDate = null
|
||||||
|
|
||||||
@ -519,7 +519,7 @@ 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
|
||||||
let probe = new Date(oldStart)
|
let probe = new Date(oldStart)
|
||||||
let safety = 0
|
let safety = 0
|
||||||
while (safety < 5000) {
|
while (safety < 5000) {
|
||||||
probe = addDays(probe, 1)
|
probe = addDays(probe, 1)
|
||||||
@ -565,9 +565,9 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEndDate = addDays(newStartDate, spanDays)
|
const newEndDate = addDays(newStartDate, spanDays)
|
||||||
base.startDate = toLocalString(newStartDate, DEFAULT_TZ)
|
base.startDate = toLocalString(newStartDate, DEFAULT_TZ)
|
||||||
base.endDate = toLocalString(newEndDate, DEFAULT_TZ)
|
base.endDate = toLocalString(newEndDate, DEFAULT_TZ)
|
||||||
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)
|
||||||
@ -579,13 +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 = fromLocalString(snapshot.startDate, DEFAULT_TZ)
|
const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ)
|
||||||
const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ)
|
const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ)
|
||||||
const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart))
|
const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart))
|
||||||
|
|
||||||
const newStart = fromLocalString(newStartStr, DEFAULT_TZ)
|
const newStart = fromLocalString(newStartStr, DEFAULT_TZ)
|
||||||
const newEnd = fromLocalString(newEndStr, DEFAULT_TZ)
|
const newEnd = fromLocalString(newEndStr, DEFAULT_TZ)
|
||||||
const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart))
|
const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart))
|
||||||
|
|
||||||
let finalDurationDays = prevDurationDays
|
let finalDurationDays = prevDurationDays
|
||||||
if (mode === 'resize-left' || mode === 'resize-right') {
|
if (mode === 'resize-left' || mode === 'resize-right') {
|
||||||
@ -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' &&
|
||||||
@ -601,8 +604,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]
|
||||||
@ -630,7 +633,7 @@ 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
|
||||||
// spanDays not needed for splitting logic here post-refactor
|
// spanDays not needed for splitting logic here post-refactor
|
||||||
const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
|
const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
|
||||||
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
|
||||||
if (occurrenceDate <= baseStart) {
|
if (occurrenceDate <= baseStart) {
|
||||||
@ -650,7 +653,7 @@ 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
|
||||||
}
|
}
|
||||||
let 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 = addDays(cursor, 1)
|
cursor = addDays(cursor, 1)
|
||||||
@ -683,7 +686,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, DEFAULT_TZ).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]
|
||||||
|
@ -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) {
|
|
||||||
return null // Dates before base start in same week are not valid occurrences
|
|
||||||
}
|
|
||||||
|
|
||||||
let occurrenceIndex = 0
|
|
||||||
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
|
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
|
||||||
// Calculate how many pattern days actually occur in the first week (from base start onward)
|
// Count pattern days in the first (possibly partial) week from baseStart..baseWeekEnd
|
||||||
let firstWeekPatternDays = 0
|
const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern)
|
||||||
let firstWeekCursor = new Date(baseStart)
|
const alignedWeeksBetween = weekDiff / interval - 1
|
||||||
const firstWeekEnd = addDays(new Date(baseBlockStart), 6) // End of first week (Sunday)
|
const fullPatternWeekCount = pattern.filter(Boolean).length
|
||||||
|
const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0
|
||||||
while (firstWeekCursor <= firstWeekEnd) {
|
// Count pattern days in the current (possibly partial) week from currentBlockStart..target
|
||||||
if (pattern[getDay(firstWeekCursor)]) firstWeekPatternDays++
|
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
|
||||||
firstWeekCursor = addDays(firstWeekCursor, 1)
|
const n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
|
||||||
}
|
return n >= event.repeatCount ? null : n
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Recurrence: Monthly -----------------------------------------------------
|
||||||
* 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) {
|
function addDaysStr(str, n, timeZone = DEFAULT_TZ) {
|
||||||
const d = fromLocalString(str, timeZone)
|
return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), 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) {
|
function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) {
|
||||||
const res = []
|
const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday
|
||||||
const base = makeTZDate(2025, 0, 6, timeZone) // Monday
|
return Array.from({ length: 7 }, (_, i) =>
|
||||||
for (let i = 0; i < 7; i++) {
|
new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format(
|
||||||
const d = addDays(base, i)
|
dateFns.addDays(monday, i),
|
||||||
res.push(
|
),
|
||||||
new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format(d)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user