Optimization
This commit is contained in:
parent
70ffd2881f
commit
895bc96899
@ -9,64 +9,25 @@ import {
|
||||
createWeekColumnScrollManager,
|
||||
createMonthScrollManager,
|
||||
} from '@/plugins/scrollManager'
|
||||
import {
|
||||
getLocalizedMonthName,
|
||||
monthAbbr,
|
||||
lunarPhaseSymbol,
|
||||
pad,
|
||||
daysInclusive,
|
||||
addDaysStr,
|
||||
formatDateRange,
|
||||
getOccurrenceIndex,
|
||||
getVirtualOccurrenceEndDate,
|
||||
getISOWeek,
|
||||
MIN_YEAR,
|
||||
MAX_YEAR,
|
||||
} from '@/utils/date'
|
||||
import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date'
|
||||
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
|
||||
import { addDays, differenceInCalendarDays, differenceInWeeks } from 'date-fns'
|
||||
import { getHolidayForDate } from '@/utils/holidays'
|
||||
import { addDays, differenceInWeeks } from 'date-fns'
|
||||
import { createVirtualWeekManager } from '@/plugins/virtualWeeks'
|
||||
|
||||
const calendarStore = useCalendarStore()
|
||||
const viewport = ref(null)
|
||||
|
||||
const emit = defineEmits(['create-event', 'edit-event'])
|
||||
|
||||
function createEventFromSelection() {
|
||||
if (!selection.value.startDate || selection.value.dayCount === 0) return null
|
||||
|
||||
return {
|
||||
startDate: selection.value.startDate,
|
||||
dayCount: selection.value.dayCount,
|
||||
endDate: addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
}
|
||||
}
|
||||
|
||||
const viewport = ref(null)
|
||||
const viewportHeight = ref(600)
|
||||
const rowHeight = ref(64)
|
||||
const rowProbe = ref(null)
|
||||
let rowProbeObserver = null
|
||||
|
||||
const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day))
|
||||
|
||||
const selection = ref({ startDate: null, dayCount: 0 })
|
||||
const isDragging = ref(false)
|
||||
const dragAnchor = ref(null)
|
||||
|
||||
watch(
|
||||
() => [calendarStore.selectedDate, calendarStore.rangeStartDate],
|
||||
() => {
|
||||
if (calendarStore.selectedDate || calendarStore.rangeStartDate) {
|
||||
scheduleDataRebuild('selection-change')
|
||||
}
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
const DOUBLE_TAP_DELAY = 300
|
||||
const pendingTap = ref({ date: null, time: 0, type: null })
|
||||
const suppressMouseUntil = ref(0)
|
||||
|
||||
function normalizeDate(val) {
|
||||
if (typeof val === 'string') return val
|
||||
if (val && typeof val === 'object') {
|
||||
@ -113,196 +74,50 @@ const contentHeight = computed(() => {
|
||||
return totalVirtualWeeks.value * rowHeight.value
|
||||
})
|
||||
|
||||
const visibleWeeks = ref([])
|
||||
let lastScrollRange = { startVW: null, endVW: null }
|
||||
let updating = 0 // 0 idle, 1 window incremental, 2 full rebuild
|
||||
function scheduleWindowUpdate(reason) {
|
||||
if (updating !== 0) return
|
||||
updating = 1
|
||||
const run = () => {
|
||||
let complete = true
|
||||
try {
|
||||
complete = updateVisibleWeeks(reason)
|
||||
} finally {
|
||||
updating = 0
|
||||
}
|
||||
if (!complete) scheduleWindowUpdate('incremental-build')
|
||||
}
|
||||
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 16 })
|
||||
else requestAnimationFrame(run)
|
||||
}
|
||||
function scheduleDataRebuild(reason) {
|
||||
if (updating === 2) return // already rebuilding
|
||||
const doRebuild = () => {
|
||||
updating = 2
|
||||
const run = () => {
|
||||
try {
|
||||
rebuildVisibleWeeks(reason)
|
||||
} finally {
|
||||
updating = 0
|
||||
}
|
||||
}
|
||||
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 60 })
|
||||
else requestAnimationFrame(run)
|
||||
}
|
||||
// If we're mid incremental window update, defer slightly to next frame
|
||||
if (updating === 1) {
|
||||
requestAnimationFrame(doRebuild)
|
||||
return
|
||||
}
|
||||
doRebuild()
|
||||
}
|
||||
// Virtual weeks manager (after dependent refs exist)
|
||||
const vwm = createVirtualWeekManager({
|
||||
calendarStore,
|
||||
viewport,
|
||||
viewportHeight,
|
||||
rowHeight,
|
||||
selection,
|
||||
baseDate,
|
||||
minVirtualWeek,
|
||||
maxVirtualWeek,
|
||||
contentHeight,
|
||||
})
|
||||
const visibleWeeks = vwm.visibleWeeks
|
||||
const { scheduleWindowUpdate, resetWeeks } = vwm
|
||||
|
||||
// Scroll managers (after scheduleWindowUpdate available)
|
||||
const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate })
|
||||
|
||||
const { scrollTop, setScrollTop, onScroll } = scrollManager
|
||||
|
||||
const weekColumnScrollManager = createWeekColumnScrollManager({
|
||||
viewport,
|
||||
viewportHeight,
|
||||
contentHeight,
|
||||
setScrollTop,
|
||||
})
|
||||
|
||||
const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } =
|
||||
weekColumnScrollManager
|
||||
|
||||
const monthScrollManager = createMonthScrollManager({
|
||||
viewport,
|
||||
viewportHeight,
|
||||
contentHeight,
|
||||
setScrollTop,
|
||||
})
|
||||
|
||||
const { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } =
|
||||
monthScrollManager
|
||||
|
||||
// Provide scroll refs to virtual week manager
|
||||
vwm.attachScroll(scrollTop, setScrollTop)
|
||||
|
||||
const initialScrollTop = computed(() => {
|
||||
const nowDate = new Date(calendarStore.now)
|
||||
const targetWeekIndex = getWeekIndex(nowDate) - 3
|
||||
return (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
})
|
||||
|
||||
const selectedDateRange = computed(() => {
|
||||
if (!selection.value.start || !selection.value.end) return ''
|
||||
return formatDateRange(
|
||||
fromLocalString(selection.value.start),
|
||||
fromLocalString(selection.value.end),
|
||||
)
|
||||
})
|
||||
|
||||
function updateVisibleWeeks(reason) {
|
||||
// Compute desired virtual week window with buffer
|
||||
const buffer = 4
|
||||
const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value
|
||||
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)
|
||||
|
||||
// Step 1: prune anything outside the desired window
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: ensure no gaps; add at most one adjacent missing week each pass
|
||||
let added = false
|
||||
const len = visibleWeeks.value.length
|
||||
if (len === 0) {
|
||||
visibleWeeks.value.push(createWeek(startVW))
|
||||
added = true
|
||||
} else {
|
||||
// Sort defensively (should already be sorted)
|
||||
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) {
|
||||
// Add one week just before current first to close front gap gradually
|
||||
visibleWeeks.value.unshift(createWeek(firstVW - 1))
|
||||
added = true
|
||||
} else {
|
||||
// Look for first internal gap
|
||||
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) {
|
||||
// Insert the immediate missing week after curVW
|
||||
visibleWeeks.value.splice(i + 1, 0, createWeek(curVW + 1))
|
||||
added = true
|
||||
gapInserted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!gapInserted && lastVW < endVW) {
|
||||
// Extend at end
|
||||
visibleWeeks.value.push(createWeek(lastVW + 1))
|
||||
added = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: assess coverage
|
||||
const firstAfter = visibleWeeks.value[0].virtualWeek
|
||||
const lastAfter = visibleWeeks.value[visibleWeeks.value.length - 1].virtualWeek
|
||||
const contiguous = (() => {
|
||||
for (let i = 0; i < visibleWeeks.value.length - 1; i++) {
|
||||
if (visibleWeeks.value[i + 1].virtualWeek !== visibleWeeks.value[i].virtualWeek + 1)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})()
|
||||
const coverageComplete =
|
||||
firstAfter <= startVW &&
|
||||
lastAfter >= endVW &&
|
||||
contiguous &&
|
||||
visibleWeeks.value.length === endVW - startVW + 1
|
||||
if (!coverageComplete) {
|
||||
// Incomplete; do not update lastScrollRange so subsequent runs keep adding
|
||||
return false
|
||||
}
|
||||
if (
|
||||
lastScrollRange.startVW === startVW &&
|
||||
lastScrollRange.endVW === endVW &&
|
||||
!added &&
|
||||
visibleWeeks.value.length
|
||||
) {
|
||||
return true
|
||||
}
|
||||
lastScrollRange = { startVW, endVW }
|
||||
return true
|
||||
}
|
||||
function rebuildVisibleWeeks(reason) {
|
||||
const buffer = 4
|
||||
const currentScrollTop = viewport.value?.scrollTop ?? scrollTop.value
|
||||
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)
|
||||
const weeks = []
|
||||
for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw))
|
||||
visibleWeeks.value = weeks
|
||||
lastScrollRange = { startVW, endVW }
|
||||
console.debug('[CalendarView] rebuildVisibleWeeks', {
|
||||
reason,
|
||||
startVW,
|
||||
endVW,
|
||||
count: weeks.length,
|
||||
})
|
||||
}
|
||||
|
||||
function computeRowHeight() {
|
||||
if (rowProbe.value) {
|
||||
const h = rowProbe.value.getBoundingClientRect().height || 64
|
||||
@ -331,179 +146,15 @@ function measureFromProbe() {
|
||||
rowHeight.value = newH
|
||||
const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH
|
||||
setScrollTop(newScrollTop, 'row-height-change')
|
||||
scheduleDataRebuild('row-height-change')
|
||||
resetWeeks('row-height-change')
|
||||
}
|
||||
}
|
||||
|
||||
function getWeekIndex(date) {
|
||||
const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7
|
||||
const firstDayOfWeek = addDays(date, -dayOffset)
|
||||
return differenceInWeeks(firstDayOfWeek, baseDate.value)
|
||||
}
|
||||
const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm
|
||||
|
||||
function getFirstDayForVirtualWeek(virtualWeek) {
|
||||
return addDays(baseDate.value, virtualWeek * 7)
|
||||
}
|
||||
// createWeek logic moved to virtualWeeks plugin
|
||||
|
||||
function createWeek(virtualWeek) {
|
||||
const firstDay = getFirstDayForVirtualWeek(virtualWeek)
|
||||
const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
|
||||
const weekNumber = getISOWeek(isoAnchor)
|
||||
const days = []
|
||||
let cur = new Date(firstDay)
|
||||
let hasFirst = false
|
||||
let monthToLabel = null
|
||||
let labelYear = null
|
||||
|
||||
const repeatingBases = []
|
||||
if (calendarStore.events) {
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (ev.isRepeating) repeatingBases.push(ev)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
||||
const storedEvents = []
|
||||
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
|
||||
storedEvents.push(ev)
|
||||
}
|
||||
}
|
||||
const dayEvents = [...storedEvents]
|
||||
// Expand repeating events into per-day occurrences (including virtual spans) when they cover this date.
|
||||
for (const base of repeatingBases) {
|
||||
// Base event's original span: include it directly as occurrence index 0.
|
||||
if (dateStr >= base.startDate && dateStr <= base.endDate) {
|
||||
dayEvents.push({
|
||||
...base,
|
||||
_recurrenceIndex: 0,
|
||||
_baseId: base.id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
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 the base span to locate a matching virtual occurrence start.
|
||||
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
||||
const candidateStart = addDays(currentDate, -offset)
|
||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||
|
||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (occurrenceIndex !== null) {
|
||||
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
||||
|
||||
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
||||
const virtualId = base.id + '_v_' + candidateStartStr
|
||||
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
||||
|
||||
if (!alreadyExists) {
|
||||
dayEvents.push({
|
||||
...base,
|
||||
id: virtualId,
|
||||
startDate: candidateStartStr,
|
||||
endDate: virtualEndDate,
|
||||
_recurrenceIndex: occurrenceIndex,
|
||||
_baseId: base.id,
|
||||
})
|
||||
}
|
||||
occurrenceFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (cur.getMonth() === 0) {
|
||||
displayText = cur.getFullYear()
|
||||
} else {
|
||||
displayText = 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: holiday,
|
||||
isHoliday: holiday !== null,
|
||||
isSelected:
|
||||
selection.value.startDate &&
|
||||
selection.value.dayCount > 0 &&
|
||||
dateStr >= selection.value.startDate &&
|
||||
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
events: dayEvents,
|
||||
})
|
||||
cur = addDays(cur, 1)
|
||||
}
|
||||
|
||||
let monthLabel = null
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear <= MAX_YEAR) {
|
||||
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)
|
||||
monthLabel = {
|
||||
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
|
||||
month: monthToLabel,
|
||||
weeksSpan: weeksSpan,
|
||||
monthClass: monthAbbr[monthToLabel],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
virtualWeek,
|
||||
weekNumber: pad(weekNumber),
|
||||
days,
|
||||
monthLabel,
|
||||
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
|
||||
}
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const top = addDays(new Date(calendarStore.now), -21)
|
||||
const targetWeekIndex = getWeekIndex(top)
|
||||
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
setScrollTop(newScrollTop, 'go-to-today')
|
||||
}
|
||||
// goToToday now provided by manager
|
||||
|
||||
function clearSelection() {
|
||||
selection.value = { startDate: null, dayCount: 0 }
|
||||
@ -658,8 +309,8 @@ onMounted(() => {
|
||||
calendarStore.updateCurrentDate()
|
||||
}, 60000)
|
||||
|
||||
// Initial build after mount & measurement
|
||||
scheduleDataRebuild('init')
|
||||
// Initial incremental build (no existing weeks yet)
|
||||
scheduleWindowUpdate('init')
|
||||
|
||||
if (window.ResizeObserver && rowProbe.value) {
|
||||
rowProbeObserver = new ResizeObserver(() => {
|
||||
@ -714,24 +365,14 @@ const handleEventClick = (payload) => {
|
||||
emit('edit-event', payload)
|
||||
}
|
||||
|
||||
const handleHeaderYearChange = ({ scrollTop: st }) => {
|
||||
const maxScroll = contentHeight.value - viewportHeight.value
|
||||
const clamped = Math.max(0, Math.min(st, isFinite(maxScroll) ? maxScroll : st))
|
||||
setScrollTop(clamped, 'header-year-change')
|
||||
// Force a full rebuild so the new year range appears instantly
|
||||
scheduleDataRebuild('header-year-change')
|
||||
}
|
||||
// header year change delegated to manager
|
||||
|
||||
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
|
||||
// We explicitly avoid locale detection; rely solely on characters present.
|
||||
// Disable rotation if any CJK Unified Ideograph or Compatibility Ideograph appears.
|
||||
function shouldRotateMonth(label) {
|
||||
if (!label) return false
|
||||
try {
|
||||
return /\p{Script=Latin}/u.test(label)
|
||||
} catch (e) {
|
||||
return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label)
|
||||
}
|
||||
}
|
||||
|
||||
// Watch first day changes (e.g., first_day config update) to adjust scroll
|
||||
@ -745,7 +386,7 @@ watch(
|
||||
const newTopWeekIndex = getWeekIndex(currentTopDate)
|
||||
const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
setScrollTop(newScroll, 'first-day-change')
|
||||
scheduleDataRebuild('first-day-change')
|
||||
resetWeeks('first-day-change')
|
||||
})
|
||||
},
|
||||
)
|
||||
@ -754,7 +395,7 @@ watch(
|
||||
watch(
|
||||
() => calendarStore.events,
|
||||
() => {
|
||||
scheduleDataRebuild('events')
|
||||
resetWeeks('events')
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@ -768,6 +409,7 @@ window.addEventListener('resize', () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-view-root">
|
||||
<div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div>
|
||||
<div class="wrap">
|
||||
<HeaderControls @go-to-today="goToToday" />
|
||||
@ -823,9 +465,13 @@ window.addEventListener('resize', () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view-root {
|
||||
display: contents;
|
||||
}
|
||||
.wrap {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
|
309
src/plugins/virtualWeeks.js
Normal file
309
src/plugins/virtualWeeks.js
Normal file
@ -0,0 +1,309 @@
|
||||
import { ref } from 'vue'
|
||||
import { addDays, differenceInWeeks, differenceInCalendarDays } from 'date-fns'
|
||||
import {
|
||||
toLocalString,
|
||||
fromLocalString,
|
||||
DEFAULT_TZ,
|
||||
getISOWeek,
|
||||
addDaysStr,
|
||||
pad,
|
||||
getLocalizedMonthName,
|
||||
monthAbbr,
|
||||
lunarPhaseSymbol,
|
||||
MAX_YEAR,
|
||||
getOccurrenceIndex,
|
||||
getVirtualOccurrenceEndDate,
|
||||
} from '@/utils/date'
|
||||
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 isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7)
|
||||
const weekNumber = getISOWeek(isoAnchor)
|
||||
const days = []
|
||||
let cur = new Date(firstDay)
|
||||
let hasFirst = false
|
||||
let monthToLabel = null
|
||||
let labelYear = null
|
||||
|
||||
const repeatingBases = []
|
||||
if (calendarStore.events) {
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (ev.isRepeating) repeatingBases.push(ev)
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(cur, DEFAULT_TZ)
|
||||
const storedEvents = []
|
||||
|
||||
for (const ev of calendarStore.events.values()) {
|
||||
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) {
|
||||
storedEvents.push(ev)
|
||||
}
|
||||
}
|
||||
const dayEvents = [...storedEvents]
|
||||
// Expand repeating events
|
||||
for (const base of repeatingBases) {
|
||||
if (dateStr >= base.startDate && dateStr <= base.endDate) {
|
||||
dayEvents.push({ ...base, _recurrenceIndex: 0, _baseId: base.id })
|
||||
continue
|
||||
}
|
||||
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
|
||||
for (let offset = 0; offset <= spanDays && !occurrenceFound; offset++) {
|
||||
const candidateStart = addDays(currentDate, -offset)
|
||||
const candidateStartStr = toLocalString(candidateStart, DEFAULT_TZ)
|
||||
const occurrenceIndex = getOccurrenceIndex(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (occurrenceIndex !== null) {
|
||||
const virtualEndDate = getVirtualOccurrenceEndDate(base, candidateStartStr, DEFAULT_TZ)
|
||||
if (dateStr >= candidateStartStr && dateStr <= virtualEndDate) {
|
||||
const virtualId = base.id + '_v_' + candidateStartStr
|
||||
const alreadyExists = dayEvents.some((ev) => ev.id === virtualId)
|
||||
if (!alreadyExists) {
|
||||
dayEvents.push({
|
||||
...base,
|
||||
id: virtualId,
|
||||
startDate: candidateStartStr,
|
||||
endDate: virtualEndDate,
|
||||
_recurrenceIndex: occurrenceIndex,
|
||||
_baseId: base.id,
|
||||
})
|
||||
}
|
||||
occurrenceFound = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if (cur.getMonth() === 0) displayText = cur.getFullYear()
|
||||
else displayText = 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:
|
||||
selection.value.startDate &&
|
||||
selection.value.dayCount > 0 &&
|
||||
dateStr >= selection.value.startDate &&
|
||||
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
events: dayEvents,
|
||||
})
|
||||
cur = addDays(cur, 1)
|
||||
}
|
||||
let monthLabel = null
|
||||
if (hasFirst && monthToLabel !== null) {
|
||||
if (labelYear && labelYear <= MAX_YEAR) {
|
||||
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)
|
||||
monthLabel = {
|
||||
text: `${getLocalizedMonthName(monthToLabel)} '${year}`,
|
||||
month: monthToLabel,
|
||||
weeksSpan,
|
||||
monthClass: monthAbbr[monthToLabel],
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
virtualWeek,
|
||||
weekNumber: pad(weekNumber),
|
||||
days,
|
||||
monthLabel,
|
||||
top: (virtualWeek - minVirtualWeek.value) * rowHeight.value,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
const top = addDays(new Date(calendarStore.now), -21)
|
||||
const targetWeekIndex = getWeekIndex(top)
|
||||
const newScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value
|
||||
if (setScrollTopFn) setScrollTopFn(newScrollTop, 'go-to-today')
|
||||
}
|
||||
|
||||
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,
|
||||
getWeekIndex,
|
||||
getFirstDayForVirtualWeek,
|
||||
goToToday,
|
||||
handleHeaderYearChange,
|
||||
attachScroll,
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user