Optimization

This commit is contained in:
Leo Vasanko 2025-08-25 18:56:49 -06:00
parent 70ffd2881f
commit 895bc96899
2 changed files with 395 additions and 440 deletions

View File

@ -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
View 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,
}
}