330 lines
11 KiB
JavaScript
330 lines
11 KiB
JavaScript
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,
|
|
}
|
|
}
|