diff --git a/src/components/CalendarView.vue b/src/components/CalendarView.vue index 3791caf..f0c4c8d 100644 --- a/src/components/CalendarView.vue +++ b/src/components/CalendarView.vue @@ -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) - } + return /\p{Script=Latin}/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,56 +409,58 @@ window.addEventListener('resize', () => {