From 70ffd2881fc21c6fddd2dc2616606a18b94fe1c7 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 25 Aug 2025 17:52:25 -0600 Subject: [PATCH] Progressive week rendering for smoother operation. --- src/components/CalendarView.vue | 146 +++++++++++++++++++++++--------- 1 file changed, 108 insertions(+), 38 deletions(-) diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index 0b453a6..3791caf 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -115,27 +115,42 @@ const contentHeight = computed(() => { const visibleWeeks = ref([]) let lastScrollRange = { startVW: null, endVW: null } -let windowTimer = null -let dataTimer = null -const WINDOW_DEBOUNCE_MS = 30 -const DATA_DEBOUNCE_MS = 40 +let updating = 0 // 0 idle, 1 window incremental, 2 full rebuild function scheduleWindowUpdate(reason) { - if (windowTimer) return - windowTimer = setTimeout(() => { - windowTimer = null - const fn = () => updateVisibleWeeks(reason) - if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 80 }) - else requestAnimationFrame(fn) - }, WINDOW_DEBOUNCE_MS) + 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 (dataTimer) return - dataTimer = setTimeout(() => { - dataTimer = null - const fn = () => rebuildVisibleWeeks(reason) - if ('requestIdleCallback' in window) requestIdleCallback(fn, { timeout: 120 }) - else requestAnimationFrame(fn) - }, DATA_DEBOUNCE_MS) + 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() } const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate }) @@ -177,6 +192,7 @@ const selectedDateRange = computed(() => { }) 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) @@ -185,34 +201,86 @@ function updateVisibleWeeks(reason) { ) const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value) const endVW = Math.min(maxVirtualWeek.value, endIdx + minVirtualWeek.value) - if ( - lastScrollRange.startVW === startVW && - lastScrollRange.endVW === endVW && - visibleWeeks.value.length - ) - return + + // Step 1: prune anything outside the desired window if (visibleWeeks.value.length) { - while (visibleWeeks.value.length && visibleWeeks.value[0].virtualWeek < startVW) + 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() - let needFirst = visibleWeeks.value[0]?.virtualWeek - if (visibleWeeks.value.length === 0) needFirst = endVW + 1 - for (let vw = (needFirst ?? startVW) - 1; vw >= startVW; vw--) - visibleWeeks.value.unshift(createWeek(vw)) - let needLast = visibleWeeks.value[visibleWeeks.value.length - 1]?.virtualWeek - for (let vw = (needLast ?? startVW - 1) + 1; vw <= endVW; vw++) - visibleWeeks.value.push(createWeek(vw)) - lastScrollRange = { startVW, endVW } - return + } + } + + // 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 } - const weeks = [] - for (let vw = startVW; vw <= endVW; vw++) weeks.push(createWeek(vw)) - visibleWeeks.value = weeks lastScrollRange = { startVW, endVW } + return true } function rebuildVisibleWeeks(reason) { const buffer = 4 @@ -650,6 +718,8 @@ 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') } // Heuristic: rotate month label (180deg) only for predominantly Latin text.