Major new version (#2)

Release Notes

Architecture
- Component refactor: removed monoliths (`Calendar.vue`, `CalendarGrid.vue`); expanded granular view + header/control components.
- Dialog system introduced (`BaseDialog`, `SettingsDialog`).

State & Data
- Store redesigned: Map-based events + recurrence map; mutation counters.
- Local persistence + undo/redo history (custom plugins).

Date & Holidays
- Migrated all date logic to `date-fns` (+ tz).
- Added national holiday support (toggle + loading utilities).

Recurrence & Events
- Consolidated recurrence handling; fixes for monthly edge days (29–31), annual, multi‑day, and complex weekly repeats.
- Reliable splitting/moving/resizing/deletion of repeating and multi‑day events.

Interaction & UX
- Double‑tap to create events; improved drag (multi‑day + position retention).
- Scroll & inertial/momentum navigation; year change via numeric scroller.
- Movable event dialog; live settings application.

Performance
- Progressive / virtual week rendering, reduced off‑screen buffer.
- Targeted repaint strategy; minimized full re-renders.

Plugins Added
- History, undo normalization, persistence, scroll manager, virtual weeks.

Styling & Layout
- Responsive + compact layout refinements; header restructured.
- Simplified visual elements (removed dots/overflow text); holiday styling adjustments.

Reliability / Fixes
- Numerous recurrence, deletion, orientation/rotation, and event indexing corrections.
- Cross-browser fallback (Firefox week info).

Dependencies Added
- date-fns, date-fns-tz, date-holidays, pinia-plugin-persistedstate.

Net Change
- 28 files modified; ~4.4K insertions / ~2.2K deletions (major refactor + feature set).
This commit is contained in:
LeoVasanko committed 2025-08-26 03:58:24 +00:00
1 parent 86a06acccb
commit dbe4c99b07
28 files changed
+4465 -2207

No files matched your search

+400
View File
@@ -0,0 +1,400 @@
import { ref } from 'vue'
import { addDays, differenceInWeeks } 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.recur) repeatingBases.push(ev)
}
}
const collectEventsForDate = (dateStr, curDateObj) => {
const storedEvents = []
for (const ev of calendarStore.events.values()) {
if (!ev.recur) {
const evEnd = toLocalString(
addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
DEFAULT_TZ,
)
if (dateStr >= ev.startDate && dateStr <= evEnd) {
storedEvents.push({ ...ev, endDate: evEnd })
}
}
}
const dayEvents = [...storedEvents]
for (const base of repeatingBases) {
const baseEnd = toLocalString(
addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1),
DEFAULT_TZ,
)
if (dateStr >= base.startDate && dateStr <= baseEnd) {
dayEvents.push({ ...base, endDate: baseEnd, _recurrenceIndex: 0, _baseId: base.id })
continue
}
const spanDays = (base.days || 1) - 1
const currentDate = curDateObj
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
}
}
}
}
return dayEvents
}
for (let i = 0; i < 7; i++) {
const dateStr = toLocalString(cur, DEFAULT_TZ)
const dayEvents = collectEventsForDate(dateStr, fromLocalString(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) {
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)
}
// Reflective update of only events inside currently visible weeks (keeps week objects stable)
function refreshEvents(reason = 'events-refresh') {
if (!visibleWeeks.value.length) return
const repeatingBases = []
if (calendarStore.events) {
for (const ev of calendarStore.events.values()) if (ev.recur) repeatingBases.push(ev)
}
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) day.isSelected = dateStr >= selStart && dateStr <= selEnd
else day.isSelected = false
// Rebuild events list for this day
const storedEvents = []
for (const ev of calendarStore.events.values()) {
if (!ev.recur) {
const evEnd = toLocalString(
addDays(fromLocalString(ev.startDate, DEFAULT_TZ), (ev.days || 1) - 1),
DEFAULT_TZ,
)
if (dateStr >= ev.startDate && dateStr <= evEnd) {
storedEvents.push({ ...ev, endDate: evEnd })
}
}
}
const dayEvents = [...storedEvents]
for (const base of repeatingBases) {
const baseEndStr = toLocalString(
addDays(fromLocalString(base.startDate, DEFAULT_TZ), (base.days || 1) - 1),
DEFAULT_TZ,
)
if (dateStr >= base.startDate && dateStr <= baseEndStr) {
dayEvents.push({ ...base, endDate: baseEndStr, _recurrenceIndex: 0, _baseId: base.id })
continue
}
const spanDays = (base.days || 1) - 1
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
}
}
}
}
day.events = dayEvents
}
}
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.debug('[VirtualWeeks] refreshEvents', reason, { weeks: visibleWeeks.value.length })
}
}
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,
refreshEvents,
getWeekIndex,
getFirstDayForVirtualWeek,
goToToday,
handleHeaderYearChange,
attachScroll,
}
}