import { ref } from 'vue' import { addDays, differenceInWeeks, isBefore, isAfter } from 'date-fns' import { toLocalString, fromLocalString, DEFAULT_TZ, getISOWeek, addDaysStr, pad, getLocalizedMonthName, monthAbbr, lunarPhaseSymbol, MAX_YEAR, } from '@/utils/date' import { buildDayEvents } from '@/utils/events' import { getHolidayForDate } from '@/utils/holidays' /** * Factory handling virtual week window & incremental building. * Exposes reactive visibleWeeks plus scheduling functions. */ export function createVirtualWeekManager({ calendarStore, viewport, viewportHeight, rowHeight, selection, baseDate, minVirtualWeek, maxVirtualWeek, contentHeight, // not currently used inside manager but kept for future }) { const visibleWeeks = ref([]) let lastScrollRange = { startVW: null, endVW: null } let updating = false // Scroll refs injected later to break cyclic dependency with scroll manager let scrollTopRef = null let setScrollTopFn = null function attachScroll(scrollTop, setScrollTop) { scrollTopRef = scrollTop setScrollTopFn = setScrollTop } function getWeekIndex(date) { const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 const firstDayOfWeek = addDays(date, -dayOffset) return differenceInWeeks(firstDayOfWeek, baseDate.value) } function getFirstDayForVirtualWeek(virtualWeek) { return addDays(baseDate.value, virtualWeek * 7) } function createWeek(virtualWeek) { const firstDay = getFirstDayForVirtualWeek(virtualWeek) const thu = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7) const weekNumber = getISOWeek(thu) const days = [] let cur = new Date(firstDay) let hasFirst = false let monthToLabel = null let labelYear = null for (let i = 0; i < 7; i++) { const dateStr = toLocalString(cur, DEFAULT_TZ) const events = buildDayEvents(calendarStore.events.values(), dateStr, DEFAULT_TZ) const dow = cur.getDay() const isFirst = cur.getDate() === 1 if (isFirst) { hasFirst = true monthToLabel = cur.getMonth() labelYear = cur.getFullYear() } let displayText = String(cur.getDate()) if (isFirst) displayText = cur.getMonth() === 0 ? cur.getFullYear() : monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase() let holiday = null if (calendarStore.config.holidays.enabled) { calendarStore._ensureHolidaysInitialized?.() holiday = getHolidayForDate(dateStr) } days.push({ date: dateStr, dayOfMonth: cur.getDate(), displayText, monthClass: monthAbbr[cur.getMonth()], isToday: dateStr === calendarStore.today, isWeekend: calendarStore.weekend[dow], isFirstDay: isFirst, lunarPhase: lunarPhaseSymbol(cur), holiday, isHoliday: holiday !== null, isSelected: isDateSelected(dateStr), events, }) cur = addDays(cur, 1) } const monthLabel = buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek }) return { virtualWeek, weekNumber: pad(weekNumber), days, monthLabel, top: (virtualWeek - minVirtualWeek.value) * rowHeight.value, } } function isDateSelected(dateStr) { if (!selection.value.startDate || selection.value.dayCount <= 0) return false const startDateObj = fromLocalString(selection.value.startDate, DEFAULT_TZ) const endDateStr = addDaysStr(selection.value.startDate, selection.value.dayCount - 1) const endDateObj = fromLocalString(endDateStr, DEFAULT_TZ) const d = fromLocalString(dateStr, DEFAULT_TZ) return !isBefore(d, startDateObj) && !isAfter(d, endDateObj) } function buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek }) { if (!hasFirst || monthToLabel === null) return null if (!labelYear || labelYear > MAX_YEAR) return null let weeksSpan = 0 const d = addDays(cur, -1) for (let i = 0; i < 6; i++) { const probe = addDays(cur, -1 + i * 7) d.setTime(probe.getTime()) if (d.getMonth() === monthToLabel) weeksSpan++ } const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1) weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks)) const year = String(labelYear).slice(-2) return { text: `${getLocalizedMonthName(monthToLabel)} '${year}`, month: monthToLabel, weeksSpan, monthClass: monthAbbr[monthToLabel], } } function internalWindowCalc() { const buffer = 6 const currentScrollTop = viewport.value?.scrollTop ?? scrollTopRef?.value ?? 0 const startIdx = Math.floor((currentScrollTop - buffer * rowHeight.value) / rowHeight.value) const endIdx = Math.ceil( (currentScrollTop + viewportHeight.value + buffer * rowHeight.value) / rowHeight.value, ) const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value) const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value) return { startVW, endVW } } function updateVisibleWeeks(_reason) { const { startVW, endVW } = internalWindowCalc() // Prune outside if (visibleWeeks.value.length) { while (visibleWeeks.value.length && visibleWeeks.value[0].virtualWeek < startVW) { visibleWeeks.value.shift() } while ( visibleWeeks.value.length && visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek > endVW ) { visibleWeeks.value.pop() } } // Add at most one week (ensuring contiguity) let added = false if (!visibleWeeks.value.length) { visibleWeeks.value.push(createWeek(startVW)) added = true } else { visibleWeeks.value.sort((a, b) => a.virtualWeek - b.virtualWeek) const firstVW = visibleWeeks.value[0].virtualWeek const lastVW = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek if (firstVW > startVW) { visibleWeeks.value.unshift(createWeek(firstVW - 1)) added = true } else { let gapInserted = false for (let i = 0; i < visibleWeeks.value.length - 1; i++) { const curVW = visibleWeeks.value[i].virtualWeek const nextVW = visibleWeeks.value[i + 1].virtualWeek if (nextVW - curVW > 1 && curVW < endVW) { visibleWeeks.value.splice(i + 1, 0, createWeek(curVW + 1)) added = true gapInserted = true break } } if (!gapInserted && lastVW < endVW) { visibleWeeks.value.push(createWeek(lastVW + 1)) added = true } } } // Coverage check const firstAfter = visibleWeeks.value[0].virtualWeek const lastAfter = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek let contiguous = true for (let i = 0; i < visibleWeeks.value.length - 1; i++) { if (visibleWeeks.value[i + 1].virtualWeek !== visibleWeeks.value[i].virtualWeek + 1) { contiguous = false break } } const coverageComplete = firstAfter <= startVW && lastAfter >= endVW && contiguous && visibleWeeks.value.length === endVW - startVW + 1 if (!coverageComplete) return false if ( lastScrollRange.startVW === startVW && lastScrollRange.endVW === endVW && !added && visibleWeeks.value.length ) { return true } lastScrollRange = { startVW, endVW } return true } function scheduleWindowUpdate(reason) { if (updating) return updating = true const run = () => { updating = false updateVisibleWeeks(reason) || scheduleWindowUpdate('incremental-build') } if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 16 }) else requestAnimationFrame(run) } function resetWeeks(reason = 'reset') { visibleWeeks.value = [] lastScrollRange = { startVW: null, endVW: null } scheduleWindowUpdate(reason) } // Reflective update of only events inside currently visible weeks (keeps week objects stable) function refreshEvents(reason = 'events-refresh') { if (!visibleWeeks.value.length) return const selStart = selection.value.startDate const selCount = selection.value.dayCount const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null for (const week of visibleWeeks.value) { for (const day of week.days) { const dateStr = day.date // Update selection flag if (selStart && selEnd) { const d = fromLocalString(dateStr, DEFAULT_TZ), s = fromLocalString(selStart, DEFAULT_TZ), e = fromLocalString(selEnd, DEFAULT_TZ) day.isSelected = !isBefore(d, s) && !isAfter(d, e) } else day.isSelected = false day.events = buildDayEvents(calendarStore.events.values(), dateStr, DEFAULT_TZ) } } if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console console.debug('[VirtualWeeks] refreshEvents', reason, { weeks: visibleWeeks.value.length }) } } // Refresh holiday data for currently visible weeks without rebuilding structure function refreshHolidays(reason = 'holidays-refresh') { if (!visibleWeeks.value.length) return const enabled = calendarStore.config.holidays.enabled if (enabled) calendarStore._ensureHolidaysInitialized?.() for (const week of visibleWeeks.value) { for (const day of week.days) { if (enabled) { const holiday = getHolidayForDate(day.date) ;((day.holiday = holiday), (day.isHoliday = holiday !== null)) } else { day.holiday = null day.isHoliday = false } } } if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console console.debug('[VirtualWeeks] refreshHolidays', reason, { weeks: visibleWeeks.value.length }) } } function goToToday() { const todayDate = new Date(calendarStore.now) const targetWeekIndex = getWeekIndex(todayDate) scrollToWeek(targetWeekIndex, 'go-to-today', true) } function scrollToWeek(weekIndex, reason = 'scroll', smooth = true) { if (weekIndex == null || !isFinite(weekIndex)) return const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) // Scroll so that the top of the viewport aligns with the top of the previous week, // making the target week the second visible week row const newScrollTop = (weekIndex - 1 - minVirtualWeek.value) * rowHeight.value const clampedScrollTop = Math.max(0, Math.min(newScrollTop, maxScroll)) if (smooth && viewport.value && typeof viewport.value.scrollTo === 'function') { viewport.value.scrollTo({ top: clampedScrollTop, behavior: 'smooth' }) } else if (setScrollTopFn) { setScrollTopFn(clampedScrollTop, reason) scheduleWindowUpdate(reason) } } function handleHeaderYearChange({ scrollTop }) { const maxScroll = contentHeight.value - viewportHeight.value const clamped = Math.max(0, Math.min(scrollTop, isFinite(maxScroll) ? maxScroll : scrollTop)) if (setScrollTopFn) setScrollTopFn(clamped, 'header-year-change') resetWeeks('header-year-change') } return { visibleWeeks, scheduleWindowUpdate, resetWeeks, updateVisibleWeeks, refreshEvents, refreshHolidays, getWeekIndex, getFirstDayForVirtualWeek, goToToday, scrollToWeek, handleHeaderYearChange, attachScroll, } }