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:
		| @@ -16,7 +16,11 @@ | ||||
|     "format": "prettier --write src/" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "date-fns": "^3.6.0", | ||||
|     "date-fns-tz": "^3.0.0", | ||||
|     "date-holidays": "^3.25.1", | ||||
|     "pinia": "^3.0.3", | ||||
|     "pinia-plugin-persistedstate": "^4.5.0", | ||||
|     "vue": "^3.5.18" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|   | ||||
							
								
								
									
										42
									
								
								src/App.vue
									
									
									
									
									
								
							
							
						
						
									
										42
									
								
								src/App.vue
									
									
									
									
									
								
							| @@ -1,9 +1,45 @@ | ||||
| <script setup> | ||||
| import { ref } from 'vue' | ||||
| import { ref, onMounted, onBeforeUnmount } from 'vue' | ||||
| import CalendarView from './components/CalendarView.vue' | ||||
| import EventDialog from './components/EventDialog.vue' | ||||
| import { useCalendarStore } from './stores/CalendarStore' | ||||
|  | ||||
| const eventDialog = ref(null) | ||||
| const calendarStore = useCalendarStore() | ||||
|  | ||||
| // Initialize holidays when app starts | ||||
| function isEditableElement(el) { | ||||
|   if (!el) return false | ||||
|   const tag = el.tagName | ||||
|   if (tag === 'INPUT' || tag === 'TEXTAREA') return true | ||||
|   if (el.isContentEditable) return true | ||||
|   return false | ||||
| } | ||||
|  | ||||
| function handleGlobalKey(e) { | ||||
|   // Only consider Ctrl/Meta+Z combos | ||||
|   if (!(e.ctrlKey || e.metaKey)) return | ||||
|   if (e.key !== 'z' && e.key !== 'Z') return | ||||
|   // Don't interfere with native undo/redo inside editable fields | ||||
|   const target = e.target | ||||
|   if (isEditableElement(target)) return | ||||
|   // Decide undo vs redo (Shift = redo) | ||||
|   if (e.shiftKey) { | ||||
|     calendarStore.$history?.redo() | ||||
|   } else { | ||||
|     calendarStore.$history?.undo() | ||||
|   } | ||||
|   e.preventDefault() | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   calendarStore.initializeHolidaysFromConfig() | ||||
|   document.addEventListener('keydown', handleGlobalKey, { passive: false }) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   document.removeEventListener('keydown', handleGlobalKey) | ||||
| }) | ||||
|  | ||||
| const handleCreateEvent = (eventData) => { | ||||
|   if (eventDialog.value) { | ||||
| @@ -15,9 +51,9 @@ const handleCreateEvent = (eventData) => { | ||||
|   } | ||||
| } | ||||
|  | ||||
| const handleEditEvent = (eventInstanceId) => { | ||||
| const handleEditEvent = (eventClickPayload) => { | ||||
|   if (eventDialog.value) { | ||||
|     eventDialog.value.openEditDialog(eventInstanceId) | ||||
|     eventDialog.value.openEditDialog(eventClickPayload) | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -20,6 +20,10 @@ | ||||
|   --label-bg: #fafbfe; | ||||
|   --label-bg-rgb: 250, 251, 254; | ||||
|  | ||||
|   /* Holiday colors */ | ||||
|   --holiday: #da0; | ||||
|   --holiday-label: var(--strong); | ||||
|  | ||||
|   /* Input / recurrence tokens */ | ||||
|   --input-border: var(--muted-alt); | ||||
|   --input-focus: var(--accent); | ||||
| @@ -34,28 +38,68 @@ | ||||
| } | ||||
|  | ||||
| /* Month tints (light) */ | ||||
| .dec { background: hsl(220 50% 95%) } | ||||
| .jan { background: hsl(220 50% 92%) } | ||||
| .feb { background: hsl(220 50% 95%) } | ||||
| .mar { background: hsl(125 60% 92%) } | ||||
| .apr { background: hsl(125 60% 95%) } | ||||
| .may { background: hsl(125 60% 92%) } | ||||
| .jun { background: hsl(45 85% 95%) } | ||||
| .jul { background: hsl(45 85% 92%) } | ||||
| .aug { background: hsl(45 85% 95%) } | ||||
| .sep { background: hsl(18 78% 92%) } | ||||
| .oct { background: hsl(18 78% 95%) } | ||||
| .nov { background: hsl(18 78% 92%) } | ||||
| .dec { | ||||
|   background: hsl(220 50% 95%); | ||||
| } | ||||
| .jan { | ||||
|   background: hsl(220 50% 92%); | ||||
| } | ||||
| .feb { | ||||
|   background: hsl(220 50% 95%); | ||||
| } | ||||
| .mar { | ||||
|   background: hsl(125 60% 92%); | ||||
| } | ||||
| .apr { | ||||
|   background: hsl(125 60% 95%); | ||||
| } | ||||
| .may { | ||||
|   background: hsl(125 60% 92%); | ||||
| } | ||||
| .jun { | ||||
|   background: hsl(45 85% 95%); | ||||
| } | ||||
| .jul { | ||||
|   background: hsl(45 85% 92%); | ||||
| } | ||||
| .aug { | ||||
|   background: hsl(45 85% 95%); | ||||
| } | ||||
| .sep { | ||||
|   background: hsl(18 78% 92%); | ||||
| } | ||||
| .oct { | ||||
|   background: hsl(18 78% 95%); | ||||
| } | ||||
| .nov { | ||||
|   background: hsl(18 78% 92%); | ||||
| } | ||||
|  | ||||
| /* Light mode — gray shades and colors */ | ||||
| .event-color-0 { background: hsl(0, 0%, 85%) }  /* lightest grey */ | ||||
| .event-color-1 { background: hsl(0, 0%, 75%) }  /* light grey */ | ||||
| .event-color-2 { background: hsl(0, 0%, 65%) }  /* medium grey */ | ||||
| .event-color-3 { background: hsl(0, 0%, 55%) }  /* dark grey */ | ||||
| .event-color-4 { background: hsl(0, 70%, 70%) }  /* red */ | ||||
| .event-color-5 { background: hsl(90, 70%, 70%) }  /* green */ | ||||
| .event-color-6 { background: hsl(230, 70%, 70%) } /* blue */ | ||||
| .event-color-7 { background: hsl(280, 70%, 70%) } /* purple */ | ||||
| .event-color-0 { | ||||
|   background: hsl(0, 0%, 85%); | ||||
| } /* lightest grey */ | ||||
| .event-color-1 { | ||||
|   background: hsl(0, 0%, 75%); | ||||
| } /* light grey */ | ||||
| .event-color-2 { | ||||
|   background: hsl(0, 0%, 65%); | ||||
| } /* medium grey */ | ||||
| .event-color-3 { | ||||
|   background: hsl(0, 0%, 55%); | ||||
| } /* dark grey */ | ||||
| .event-color-4 { | ||||
|   background: hsl(0, 70%, 70%); | ||||
| } /* red */ | ||||
| .event-color-5 { | ||||
|   background: hsl(90, 70%, 70%); | ||||
| } /* green */ | ||||
| .event-color-6 { | ||||
|   background: hsl(230, 70%, 70%); | ||||
| } /* blue */ | ||||
| .event-color-7 { | ||||
|   background: hsl(280, 70%, 70%); | ||||
| } /* purple */ | ||||
|  | ||||
| /* Color tokens (dark) */ | ||||
| @media (prefers-color-scheme: dark) { | ||||
| @@ -90,27 +134,71 @@ | ||||
|     /* Vue component color mappings (dark) */ | ||||
|     --bg: var(--panel); | ||||
|     --border-color: #333; | ||||
|  | ||||
|     /* Holiday colors (dark mode) */ | ||||
|     --holiday: #ffc107; | ||||
|     --holiday-label: #fff8e1; | ||||
|   } | ||||
|  | ||||
|   .dec { background: hsl(220 50% 8%) } | ||||
|   .jan { background: hsl(220 50% 6%) } | ||||
|   .feb { background: hsl(220 50% 8%) } | ||||
|   .mar { background: hsl(125 60% 6%) } | ||||
|   .apr { background: hsl(125 60% 8%) } | ||||
|   .may { background: hsl(125 60% 6%) } | ||||
|   .jun { background: hsl(45 85% 8%) } | ||||
|   .jul { background: hsl(45 85% 6%) } | ||||
|   .aug { background: hsl(45 85% 8%) } | ||||
|   .sep { background: hsl(18 78% 6%) } | ||||
|   .oct { background: hsl(18 78% 8%) } | ||||
|   .nov { background: hsl(18 78% 6%) } | ||||
|  | ||||
|   .event-color-0 { background: hsl(0, 0%, 50%) }  /* lightest grey */ | ||||
|   .event-color-1 { background: hsl(0, 0%, 40%) }  /* light grey */ | ||||
|   .event-color-2 { background: hsl(0, 0%, 30%) }  /* medium grey */ | ||||
|   .event-color-3 { background: hsl(0, 0%, 20%) }  /* dark grey */ | ||||
|   .event-color-4 { background: hsl(0, 70%, 40%) }  /* red */ | ||||
|   .event-color-5 { background: hsl(90, 70%, 30%) }  /* green - darker for perceptional purposes */ | ||||
|   .event-color-6 { background: hsl(230, 70%, 40%) } /* blue */ | ||||
|   .event-color-7 { background: hsl(280, 70%, 40%) } /* purple */ | ||||
|   .dec { | ||||
|     background: hsl(220 50% 8%); | ||||
|   } | ||||
|   .jan { | ||||
|     background: hsl(220 50% 6%); | ||||
|   } | ||||
|   .feb { | ||||
|     background: hsl(220 50% 8%); | ||||
|   } | ||||
|   .mar { | ||||
|     background: hsl(125 60% 6%); | ||||
|   } | ||||
|   .apr { | ||||
|     background: hsl(125 60% 8%); | ||||
|   } | ||||
|   .may { | ||||
|     background: hsl(125 60% 6%); | ||||
|   } | ||||
|   .jun { | ||||
|     background: hsl(45 85% 8%); | ||||
|   } | ||||
|   .jul { | ||||
|     background: hsl(45 85% 6%); | ||||
|   } | ||||
|   .aug { | ||||
|     background: hsl(45 85% 8%); | ||||
|   } | ||||
|   .sep { | ||||
|     background: hsl(18 78% 6%); | ||||
|   } | ||||
|   .oct { | ||||
|     background: hsl(18 78% 8%); | ||||
|   } | ||||
|   .nov { | ||||
|     background: hsl(18 78% 6%); | ||||
|   } | ||||
|  | ||||
|   .event-color-0 { | ||||
|     background: hsl(0, 0%, 50%); | ||||
|   } /* lightest grey */ | ||||
|   .event-color-1 { | ||||
|     background: hsl(0, 0%, 40%); | ||||
|   } /* light grey */ | ||||
|   .event-color-2 { | ||||
|     background: hsl(0, 0%, 30%); | ||||
|   } /* medium grey */ | ||||
|   .event-color-3 { | ||||
|     background: hsl(0, 0%, 20%); | ||||
|   } /* dark grey */ | ||||
|   .event-color-4 { | ||||
|     background: hsl(0, 70%, 40%); | ||||
|   } /* red */ | ||||
|   .event-color-5 { | ||||
|     background: hsl(90, 70%, 30%); | ||||
|   } /* green - darker for perceptional purposes */ | ||||
|   .event-color-6 { | ||||
|     background: hsl(230, 70%, 40%); | ||||
|   } /* blue */ | ||||
|   .event-color-7 { | ||||
|     background: hsl(280, 70%, 40%); | ||||
|   } /* purple */ | ||||
| } | ||||
|   | ||||
| @@ -1,53 +1,62 @@ | ||||
| /* Layout variables */ | ||||
| :root { | ||||
|   /* Layout */ | ||||
|   --row-h: 2.2em; | ||||
|   --label-w: minmax(4em, 8%); | ||||
|   --cell-w: 1fr; | ||||
|   --cell-h: clamp(4em, 8vh, 8em); | ||||
|   --overlay-w: minmax(3rem, 5%); | ||||
|   --week-w: 3rem; | ||||
|   --day-w: 1fr; | ||||
|   --month-w: 2rem; | ||||
|   --row-h: 15vh; | ||||
| } | ||||
| * { | ||||
|   box-sizing: border-box; | ||||
| } | ||||
|  | ||||
| /* Layout & typography */ | ||||
| * { box-sizing: border-box } | ||||
| html, | ||||
| body { | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   margin: 0; | ||||
|   font: 500 14px/1.2 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial; | ||||
|   font: | ||||
|     500 14px/1.2 ui-sans-serif, | ||||
|     system-ui, | ||||
|     -apple-system, | ||||
|     Segoe UI, | ||||
|     Roboto, | ||||
|     Inter, | ||||
|     Arial; | ||||
|   background: var(--bg); | ||||
|   color: var(--ink); | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| header { | ||||
|   display: flex; | ||||
|   align-items: baseline; | ||||
|   justify-content: space-between; | ||||
|   margin-bottom: .75rem; | ||||
|   margin-bottom: 0.75rem; | ||||
|   flex-shrink: 0; | ||||
| } | ||||
|  | ||||
| .header-controls { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: .75rem; | ||||
| .today-date { | ||||
|   cursor: pointer; | ||||
| } | ||||
|  | ||||
| .today-date { cursor: pointer } | ||||
| .today-date::first-line { color: var(--today) } | ||||
| .today-button:hover { opacity: .8 } | ||||
|  | ||||
| /* Header row */ | ||||
| .calendar-header, #calendar-header { | ||||
| .today-date::first-line { | ||||
|   color: var(--today); | ||||
| } | ||||
| .today-button:hover { | ||||
|   opacity: 0.8; | ||||
| } | ||||
| .calendar-header, | ||||
| #calendar-header { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w); | ||||
|   border-bottom: .2em solid var(--muted); | ||||
|   grid-template-columns: var(--week-w) repeat(7, var(--day-w)) var(--month-w); | ||||
|   border-bottom: 0.2em solid var(--muted); | ||||
|   align-items: last baseline; | ||||
|   flex-shrink: 0; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| /* Main container */ | ||||
| .calendar-container, #calendar-container { | ||||
| .calendar-container, | ||||
| #calendar-container { | ||||
|   flex: 1; | ||||
|   overflow: hidden; | ||||
|   position: relative; | ||||
| @@ -56,7 +65,8 @@ header { | ||||
| } | ||||
|  | ||||
| /* Viewports (support id or class) */ | ||||
| .calendar-viewport, #calendar-viewport { | ||||
| .calendar-viewport, | ||||
| #calendar-viewport { | ||||
|   height: 100%; | ||||
|   overflow-y: auto; | ||||
|   overflow-x: hidden; | ||||
| @@ -65,37 +75,27 @@ header { | ||||
|   scrollbar-width: none; | ||||
| } | ||||
| .calendar-viewport::-webkit-scrollbar, | ||||
| #calendar-viewport::-webkit-scrollbar { display: none } | ||||
|  | ||||
| .jogwheel-viewport, #jogwheel-viewport { | ||||
|   position: absolute; | ||||
|   top: 0; right: 0; bottom: 0; | ||||
|   width: var(--overlay-w); | ||||
|   overflow-y: auto; | ||||
|   overflow-x: hidden; | ||||
|   scrollbar-width: none; | ||||
|   z-index: 20; | ||||
|   cursor: ns-resize; | ||||
| #calendar-viewport::-webkit-scrollbar { | ||||
|   display: none; | ||||
| } | ||||
| .calendar-content, | ||||
| #calendar-content { | ||||
|   position: relative; | ||||
| } | ||||
| .jogwheel-viewport::-webkit-scrollbar, | ||||
| #jogwheel-viewport::-webkit-scrollbar { display: none } | ||||
|  | ||||
| .jogwheel-content, #jogwheel-content { position: relative; width: 100% } | ||||
| .calendar-content, #calendar-content { position: relative } | ||||
|  | ||||
| /* Week row: label + 7-day grid + jogwheel column */ | ||||
| .week-row { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--label-w) repeat(7, var(--cell-w)) var(--overlay-w); | ||||
|   grid-template-columns: var(--week-w) repeat(7, var(--day-w)) var(--month-w); | ||||
|   position: relative; | ||||
|   overflow: visible; | ||||
|   height: var(--cell-h); | ||||
|   height: var(--row-h); | ||||
|   scroll-snap-align: start; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| /* Label cells */ | ||||
| .year-label, .week-label { | ||||
| .year-label, | ||||
| .week-label { | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   width: 100%; | ||||
| @@ -105,7 +105,7 @@ header { | ||||
| } | ||||
|  | ||||
| .week-label { | ||||
|   height: var(--cell-h); | ||||
|   height: var(--row-h); | ||||
| } | ||||
| /* 7-day grid inside each week row */ | ||||
| .week-row > .days-grid { | ||||
| @@ -130,7 +130,8 @@ header { | ||||
|   z-index: 15; | ||||
|   overflow: visible; | ||||
|   position: absolute; | ||||
|   top: 0; right: 0; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   width: 100%; | ||||
| } | ||||
| .month-name-label > span { | ||||
|   | ||||
							
								
								
									
										264
									
								
								src/components/BaseDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										264
									
								
								src/components/BaseDialog.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,264 @@ | ||||
| <script setup> | ||||
| import { ref, computed, watch, onMounted, onUnmounted, nextTick, useAttrs } from 'vue' | ||||
|  | ||||
| // Disable automatic attr inheritance so we can forward class/style specifically to the modal element | ||||
| defineOptions({ inheritAttrs: false }) | ||||
|  | ||||
| const props = defineProps({ | ||||
|   modelValue: { type: Boolean, default: false }, | ||||
|   title: { type: String, default: '' }, | ||||
|   draggable: { type: Boolean, default: true }, | ||||
|   autoFocus: { type: Boolean, default: true }, | ||||
|   // Optional external anchor element (e.g., a day cell) to position the dialog below. | ||||
|   // If not provided, falls back to internal anchorRef span (original behavior). | ||||
|   anchorEl: { type: Object, default: null }, | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['update:modelValue', 'opened', 'closed', 'submit']) | ||||
|  | ||||
| const modalRef = ref(null) | ||||
| const anchorRef = ref(null) | ||||
| const isDragging = ref(false) | ||||
| const dragOffset = ref({ x: 0, y: 0 }) | ||||
| const modalPosition = ref({ x: 0, y: 0 }) | ||||
| const dialogWidth = ref(null) | ||||
| const dialogHeight = ref(null) | ||||
| const hasMoved = ref(false) | ||||
| const margin = 8 // viewport margin in px to keep dialog from touching edges | ||||
|  | ||||
| // Collect incoming non-prop attributes (e.g., class / style from usage site) | ||||
| const attrs = useAttrs() | ||||
|  | ||||
| function clamp(val, min, max) { | ||||
|   return Math.min(Math.max(val, min), max) | ||||
| } | ||||
|  | ||||
| function startDrag(event) { | ||||
|   if (!props.draggable || !modalRef.value) return | ||||
|   const rect = modalRef.value.getBoundingClientRect() | ||||
|   // Lock current size so moving doesn't cause reflow / resize | ||||
|   dialogWidth.value = rect.width | ||||
|   dialogHeight.value = rect.height | ||||
|   // Initialize position to current on-screen coordinates BEFORE enabling moved mode | ||||
|   modalPosition.value = { x: rect.left, y: rect.top } | ||||
|   isDragging.value = true | ||||
|   hasMoved.value = true | ||||
|   dragOffset.value = { x: event.clientX - rect.left, y: event.clientY - rect.top } | ||||
|   if (event.pointerId !== undefined) { | ||||
|     try { | ||||
|       event.target.setPointerCapture(event.pointerId) | ||||
|     } catch {} | ||||
|   } | ||||
|   document.addEventListener('pointermove', handleDrag, { passive: false }) | ||||
|   document.addEventListener('pointerup', stopDrag) | ||||
|   document.addEventListener('pointercancel', stopDrag) | ||||
|   event.preventDefault() | ||||
| } | ||||
| function handleDrag(event) { | ||||
|   if (!isDragging.value) return | ||||
|   let x = event.clientX - dragOffset.value.x | ||||
|   let y = event.clientY - dragOffset.value.y | ||||
|   const w = dialogWidth.value || modalRef.value?.offsetWidth || 0 | ||||
|   const h = dialogHeight.value || modalRef.value?.offsetHeight || 0 | ||||
|   const vw = window.innerWidth | ||||
|   const vh = window.innerHeight | ||||
|   x = clamp(x, margin, Math.max(margin, vw - w - margin)) | ||||
|   y = clamp(y, margin, Math.max(margin, vh - h - margin)) | ||||
|   modalPosition.value = { x, y } | ||||
|   event.preventDefault() | ||||
| } | ||||
| function stopDrag() { | ||||
|   isDragging.value = false | ||||
|   document.removeEventListener('pointermove', handleDrag) | ||||
|   document.removeEventListener('pointerup', stopDrag) | ||||
|   document.removeEventListener('pointercancel', stopDrag) | ||||
| } | ||||
|  | ||||
| const modalStyle = computed(() => { | ||||
|   // Always position relative to calculated modalPosition once opened | ||||
|   if (modalRef.value && props.modelValue) { | ||||
|     const style = { | ||||
|       transform: 'none', | ||||
|       left: modalPosition.value.x + 'px', | ||||
|       top: modalPosition.value.y + 'px', | ||||
|       bottom: 'auto', | ||||
|       right: 'auto', | ||||
|     } | ||||
|     if (hasMoved.value) { | ||||
|       style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined | ||||
|       style.height = dialogHeight.value ? dialogHeight.value + 'px' : undefined | ||||
|     } | ||||
|     return style | ||||
|   } | ||||
|   return {} | ||||
| }) | ||||
|  | ||||
| // Merge external class / style with internal ones so parent usage like | ||||
| // <BaseDialog class="settings-modal" :style="{ top: '...' }" /> works even with fragment root. | ||||
| const modalAttrs = computed(() => { | ||||
|   const { class: extClass, style: extStyle, ...rest } = attrs | ||||
|   return { | ||||
|     ...rest, | ||||
|     class: ['ec-modal', extClass].filter(Boolean), | ||||
|     style: [modalStyle.value, extStyle].filter(Boolean), // external style overrides internal | ||||
|   } | ||||
| }) | ||||
|  | ||||
| function close() { | ||||
|   emit('update:modelValue', false) | ||||
|   emit('closed') | ||||
| } | ||||
|  | ||||
| function handleKeydown(e) { | ||||
|   if (e.key === 'Escape' && props.modelValue) close() | ||||
| } | ||||
|  | ||||
| onMounted(() => document.addEventListener('keydown', handleKeydown)) | ||||
| onUnmounted(() => document.removeEventListener('keydown', handleKeydown)) | ||||
|  | ||||
| function positionNearAnchor() { | ||||
|   const anchor = props.anchorEl || anchorRef.value | ||||
|   if (!anchor) return | ||||
|   const rect = anchor.getBoundingClientRect() | ||||
|   const offsetY = 8 // vertical gap below the anchor | ||||
|   const w = modalRef.value?.offsetWidth || dialogWidth.value || 320 | ||||
|   const h = modalRef.value?.offsetHeight || dialogHeight.value || 200 | ||||
|   const vw = window.innerWidth | ||||
|   const vh = window.innerHeight | ||||
|   let x = rect.left | ||||
|   let y = rect.bottom + offsetY | ||||
|   // If anchor is wider than dialog and would overflow right edge, clamp; otherwise keep left align | ||||
|   x = clamp(x, margin, Math.max(margin, vw - w - margin)) | ||||
|   y = clamp(y, margin, Math.max(margin, vh - h - margin)) | ||||
|   modalPosition.value = { x, y } | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   () => props.modelValue, | ||||
|   async (v) => { | ||||
|     if (v) { | ||||
|       emit('opened') | ||||
|       await nextTick() | ||||
|       // Reset movement state each time opened | ||||
|       hasMoved.value = false | ||||
|       dialogWidth.value = null | ||||
|       dialogHeight.value = null | ||||
|       positionNearAnchor() | ||||
|       if (props.autoFocus) { | ||||
|         const el = modalRef.value?.querySelector('[autofocus]') | ||||
|         if (el) el.focus() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| // Reposition if anchorEl changes while open and user hasn't dragged dialog yet | ||||
| watch( | ||||
|   () => props.anchorEl, | ||||
|   () => { | ||||
|     if (props.modelValue && !hasMoved.value) { | ||||
|       nextTick(() => positionNearAnchor()) | ||||
|     } | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| function handleResize() { | ||||
|   if (!props.modelValue) return | ||||
|   // Re-clamp current position, and if not moved recalc near anchor | ||||
|   if (!hasMoved.value) positionNearAnchor() | ||||
|   else if (modalRef.value) { | ||||
|     const w = modalRef.value.offsetWidth | ||||
|     const h = modalRef.value.offsetHeight | ||||
|     const vw = window.innerWidth | ||||
|     const vh = window.innerHeight | ||||
|     modalPosition.value = { | ||||
|       x: clamp(modalPosition.value.x, margin, Math.max(margin, vw - w - margin)), | ||||
|       y: clamp(modalPosition.value.y, margin, Math.max(margin, vh - h - margin)), | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   window.addEventListener('resize', handleResize) | ||||
| }) | ||||
| onUnmounted(() => { | ||||
|   window.removeEventListener('resize', handleResize) | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <span ref="anchorRef" class="ec-modal-anchor" aria-hidden="true"></span> | ||||
|   <div v-if="modelValue" ref="modalRef" v-bind="modalAttrs"> | ||||
|     <form class="ec-form" @submit.prevent="emit('submit')"> | ||||
|       <header class="ec-header" @pointerdown="startDrag"> | ||||
|         <h2 class="ec-title"> | ||||
|           <slot name="title">{{ title }}</slot> | ||||
|         </h2> | ||||
|         <div class="ec-header-extra"><slot name="header-extra" /></div> | ||||
|       </header> | ||||
|       <div class="ec-body"> | ||||
|         <slot /> | ||||
|       </div> | ||||
|       <footer v-if="$slots.footer" class="ec-footer"> | ||||
|         <slot name="footer" /> | ||||
|       </footer> | ||||
|     </form> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| .ec-modal { | ||||
|   position: fixed; /* still fixed for overlay & dragging, but now top/left are set dynamically */ | ||||
|   background: color-mix(in srgb, var(--panel) 85%, transparent); | ||||
|   backdrop-filter: blur(0.625em); | ||||
|   -webkit-backdrop-filter: blur(0.625em); | ||||
|   color: var(--ink); | ||||
|   border-radius: 0.6em; | ||||
|   min-height: 23em; | ||||
|   min-width: 26em; | ||||
|   max-width: min(34em, 90vw); | ||||
|   box-shadow: 0 0.6em 1.8em rgba(0, 0, 0, 0.35); | ||||
|   border: 0.0625em solid color-mix(in srgb, var(--muted) 40%, transparent); | ||||
|   z-index: 1000; | ||||
|   overflow: hidden; | ||||
| } | ||||
| .ec-modal-anchor { | ||||
|   display: inline-block; | ||||
|   width: 0; | ||||
|   height: 0; | ||||
| } | ||||
| .ec-form { | ||||
|   display: grid; | ||||
|   grid-template-rows: auto 1fr auto; | ||||
|   min-height: 23em; | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
| } | ||||
| .ec-header { | ||||
|   cursor: move; | ||||
|   user-select: none; | ||||
|   padding: 0.75em 1em 0.5em 1em; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   gap: 1em; | ||||
| } | ||||
| .ec-title { | ||||
|   margin: 0; | ||||
|   font-size: 1.1em; | ||||
| } | ||||
| .ec-body { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   gap: 1em; | ||||
|   padding: 0 1em 0.5em 1em; | ||||
|   overflow: auto; | ||||
| } | ||||
| .ec-footer { | ||||
|   padding: 0.5em 1em 1em 1em; | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   gap: 1em; | ||||
|   flex-wrap: wrap; | ||||
| } | ||||
| </style> | ||||
| @@ -1,35 +0,0 @@ | ||||
| <template> | ||||
|   <div class="wrap"> | ||||
|     <AppHeader /> | ||||
|     <div class="calendar-container" ref="containerEl"> | ||||
|       <CalendarGrid /> | ||||
|       <Jogwheel /> | ||||
|     </div> | ||||
|     <EventDialog /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted, onBeforeUnmount } from 'vue' | ||||
| import AppHeader from './AppHeader.vue' | ||||
| import CalendarGrid from './CalendarGrid.vue' | ||||
| import Jogwheel from './Jogwheel.vue' | ||||
| import EventDialog from './EventDialog.vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
|  | ||||
| const calendarStore = useCalendarStore() | ||||
| const containerEl = ref(null) | ||||
|  | ||||
| let intervalId | ||||
|  | ||||
| onMounted(() => { | ||||
|   calendarStore.setToday() | ||||
|   intervalId = setInterval(() => { | ||||
|     calendarStore.setToday() | ||||
|   }, 1000) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   clearInterval(intervalId) | ||||
| }) | ||||
| </script> | ||||
| @@ -1,18 +1,14 @@ | ||||
| <script setup> | ||||
| const props = defineProps({ | ||||
|   day: Object, | ||||
|   dragging: { type: Boolean, default: false }, | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['event-click']) | ||||
|  | ||||
| const handleEventClick = (eventId) => { | ||||
|   emit('event-click', eventId) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div | ||||
|     class="cell" | ||||
|     :style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'" | ||||
|     :class="[ | ||||
|       props.day.monthClass, | ||||
|       { | ||||
| @@ -20,6 +16,7 @@ const handleEventClick = (eventId) => { | ||||
|         weekend: props.day.isWeekend, | ||||
|         firstday: props.day.isFirstDay, | ||||
|         selected: props.day.isSelected, | ||||
|         holiday: props.day.isHoliday, | ||||
|       }, | ||||
|     ]" | ||||
|     :data-date="props.day.date" | ||||
| @@ -27,19 +24,10 @@ const handleEventClick = (eventId) => { | ||||
|     <h1>{{ props.day.displayText }}</h1> | ||||
|     <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span> | ||||
|  | ||||
|     <!-- Simple event display for now --> | ||||
|     <div v-if="props.day.events && props.day.events.length > 0" class="day-events"> | ||||
|       <div | ||||
|         v-for="event in props.day.events.slice(0, 3)" | ||||
|         :key="event.id" | ||||
|         class="event-dot" | ||||
|         :class="`event-color-${event.colorId}`" | ||||
|         :title="event.title" | ||||
|         @click.stop="handleEventClick(event.id)" | ||||
|       ></div> | ||||
|       <div v-if="props.day.events.length > 3" class="event-more"> | ||||
|         +{{ props.day.events.length - 3 }} | ||||
|       </div> | ||||
|     <div v-if="props.day.holiday" class="holiday-info"> | ||||
|       <span class="holiday-name" :title="props.day.holiday.name"> | ||||
|         {{ props.day.holiday.name }} | ||||
|       </span> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -50,7 +38,6 @@ const handleEventClick = (eventId) => { | ||||
|   border-right: 1px solid var(--border-color); | ||||
|   border-bottom: 1px solid var(--border-color); | ||||
|   user-select: none; | ||||
|   touch-action: none; | ||||
|   display: flex; | ||||
|   flex-direction: row; | ||||
|   align-items: flex-start; | ||||
| @@ -58,7 +45,7 @@ const handleEventClick = (eventId) => { | ||||
|   padding: 0.25em; | ||||
|   overflow: hidden; | ||||
|   width: 100%; | ||||
|   height: var(--cell-h); | ||||
|   height: var(--row-h); | ||||
|   font-weight: 700; | ||||
|   transition: background-color 0.15s ease; | ||||
| } | ||||
| @@ -72,20 +59,6 @@ const handleEventClick = (eventId) => { | ||||
|   color: var(--ink); | ||||
|   transition: background-color 0.15s ease; | ||||
| } | ||||
|  | ||||
| .cell.today h1 { | ||||
|   border-radius: 2em; | ||||
|   background: var(--today); | ||||
|   border: 0.2em solid var(--today); | ||||
|   margin: -0.2em; | ||||
|   color: white; | ||||
|   font-weight: bold; | ||||
| } | ||||
|  | ||||
| .cell:hover h1 { | ||||
|   text-shadow: 0 0 0.2em var(--shadow); | ||||
| } | ||||
|  | ||||
| .cell.weekend h1 { | ||||
|   color: var(--weekend); | ||||
| } | ||||
| @@ -93,18 +66,64 @@ const handleEventClick = (eventId) => { | ||||
|   color: var(--firstday); | ||||
|   text-shadow: 0 0 0.1em var(--strong); | ||||
| } | ||||
| .cell.today h1 { | ||||
|   border-radius: 2em; | ||||
|   background: var(--today); | ||||
|   border: 0.2em solid var(--today); | ||||
|   margin: -0.2em; | ||||
|   color: var(--strong); | ||||
|   font-weight: bold; | ||||
| } | ||||
| .cell.selected { | ||||
|   filter: hue-rotate(180deg); | ||||
| } | ||||
| .cell.selected h1 { | ||||
|   color: var(--strong); | ||||
| } | ||||
|  | ||||
| .lunar-phase { | ||||
|   position: absolute; | ||||
|   top: 0.1em; | ||||
|   right: 0.1em; | ||||
|   top: 0.5em; | ||||
|   right: 0.2em; | ||||
|   font-size: 0.8em; | ||||
|   opacity: 0.7; | ||||
| } | ||||
| .cell.holiday { | ||||
|   background-image: linear-gradient( | ||||
|     135deg, | ||||
|     var(--holiday-grad-start, rgba(255, 255, 255, 0.5)) 0%, | ||||
|     var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% | ||||
|   ); | ||||
| } | ||||
| @media (prefers-color-scheme: dark) { | ||||
|   .cell.holiday { | ||||
|     background-image: linear-gradient( | ||||
|       135deg, | ||||
|       var(--holiday-grad-start, rgba(255, 255, 255, 0.1)) 0%, | ||||
|       var(--holiday-grad-end, rgba(255, 255, 255, 0)) 70% | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| .cell.holiday h1 { | ||||
|   /* Slight emphasis without forcing a specific hue */ | ||||
|   color: var(--holiday); | ||||
|   text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4); | ||||
| } | ||||
| .holiday-info { | ||||
|   position: absolute; | ||||
|   bottom: 0.1em; | ||||
|   left: 0.1em; | ||||
|   right: 0.1em; | ||||
|   line-height: 1; | ||||
|   overflow: hidden; | ||||
|   font-size: clamp(1.2vw, 0.6em, 1em); | ||||
| } | ||||
|  | ||||
| .holiday-name { | ||||
|   display: block; | ||||
|   color: var(--holiday-label); | ||||
|   padding: 0.15em 0.35em 0.15em 0.25em; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
|   overflow: hidden; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -1,184 +0,0 @@ | ||||
| <template> | ||||
|   <div class="calendar-header"> | ||||
|     <div class="year-label" @wheel.prevent="handleWheel">{{ calendarStore.viewYear }}</div> | ||||
|     <div v-for="day in weekdayNames" :key="day" class="dow" :class="{ weekend: isWeekend(day) }"> | ||||
|       {{ day }} | ||||
|     </div> | ||||
|     <div class="overlay-header-spacer"></div> | ||||
|   </div> | ||||
|   <div class="calendar-viewport" ref="viewportEl" @scroll="handleScroll"> | ||||
|     <div class="calendar-content" :style="{ height: `${totalVirtualWeeks * rowHeight}px` }"> | ||||
|       <WeekRow | ||||
|         v.for="week in visibleWeeks" | ||||
|         :key="week.virtualWeek" | ||||
|         :week="week" | ||||
|         :style="{ top: `${(week.virtualWeek - minVirtualWeek) * rowHeight}px` }" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, onMounted, onBeforeUnmount, computed } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import { | ||||
|   getLocalizedWeekdayNames, | ||||
|   getLocaleWeekendDays, | ||||
|   getLocaleFirstDay, | ||||
|   isoWeekInfo, | ||||
|   fromLocalString, | ||||
|   toLocalString, | ||||
|   mondayIndex, | ||||
| } from '@/utils/date' | ||||
| import WeekRow from './WeekRow.vue' | ||||
|  | ||||
| const calendarStore = useCalendarStore() | ||||
| const viewportEl = ref(null) | ||||
| const rowHeight = ref(64) // Default value, will be computed | ||||
| const totalVirtualWeeks = ref(0) | ||||
| const minVirtualWeek = ref(0) | ||||
| const visibleWeeks = ref([]) | ||||
|  | ||||
| const config = { | ||||
|   min_year: 1900, | ||||
|   max_year: 2100, | ||||
|   weekend: getLocaleWeekendDays(), | ||||
| } | ||||
|  | ||||
| const baseDate = new Date(1970, 0, 4 + getLocaleFirstDay()) | ||||
| const WEEK_MS = 7 * 86400000 | ||||
|  | ||||
| const weekdayNames = getLocalizedWeekdayNames() | ||||
|  | ||||
| const isWeekend = (day) => { | ||||
|   const dayIndex = weekdayNames.indexOf(day) | ||||
|   return config.weekend[(dayIndex + 1) % 7] | ||||
| } | ||||
|  | ||||
| const getWeekIndex = (date) => { | ||||
|   const monday = new Date(date) | ||||
|   monday.setDate(date.getDate() - mondayIndex(date)) | ||||
|   return Math.floor((monday - baseDate) / WEEK_MS) | ||||
| } | ||||
|  | ||||
| const getMondayForVirtualWeek = (virtualWeek) => { | ||||
|   const monday = new Date(baseDate) | ||||
|   monday.setDate(monday.getDate() + virtualWeek * 7) | ||||
|   return monday | ||||
| } | ||||
|  | ||||
| const computeRowHeight = () => { | ||||
|   const el = document.createElement('div') | ||||
|   el.style.position = 'absolute' | ||||
|   el.style.visibility = 'hidden' | ||||
|   el.style.height = 'var(--cell-h)' | ||||
|   document.body.appendChild(el) | ||||
|   const h = el.getBoundingClientRect().height || 64 | ||||
|   el.remove() | ||||
|   return Math.round(h) | ||||
| } | ||||
|  | ||||
| const updateVisibleWeeks = () => { | ||||
|   if (!viewportEl.value) return | ||||
|  | ||||
|   const scrollTop = viewportEl.value.scrollTop | ||||
|   const viewportH = viewportEl.value.clientHeight | ||||
|  | ||||
|   const topDisplayIndex = Math.floor(scrollTop / rowHeight.value) | ||||
|   const topVW = topDisplayIndex + minVirtualWeek.value | ||||
|   const monday = getMondayForVirtualWeek(topVW) | ||||
|   const { year } = isoWeekInfo(monday) | ||||
|   if (calendarStore.viewYear !== year) { | ||||
|     calendarStore.setViewYear(year) | ||||
|   } | ||||
|  | ||||
|   const buffer = 10 | ||||
|   const startIdx = Math.floor((scrollTop - buffer * rowHeight.value) / rowHeight.value) | ||||
|   const endIdx = Math.ceil((scrollTop + viewportH + buffer * rowHeight.value) / rowHeight.value) | ||||
|  | ||||
|   const startVW = Math.max(minVirtualWeek.value, startIdx + minVirtualWeek.value) | ||||
|   const endVW = Math.min( | ||||
|     totalVirtualWeeks.value + minVirtualWeek.value - 1, | ||||
|     endIdx + minVirtualWeek.value, | ||||
|   ) | ||||
|  | ||||
|   const newVisibleWeeks = [] | ||||
|   for (let vw = startVW; vw <= endVW; vw++) { | ||||
|     newVisibleWeeks.push({ | ||||
|       virtualWeek: vw, | ||||
|       monday: getMondayForVirtualWeek(vw), | ||||
|     }) | ||||
|   } | ||||
|   visibleWeeks.value = newVisibleWeeks | ||||
| } | ||||
|  | ||||
| const handleScroll = () => { | ||||
|   requestAnimationFrame(updateVisibleWeeks) | ||||
| } | ||||
|  | ||||
| const handleWheel = (e) => { | ||||
|   const currentYear = calendarStore.viewYear | ||||
|   const delta = Math.round(e.deltaY * (1 / 3)) | ||||
|   if (!delta) return | ||||
|   const newYear = Math.max(config.min_year, Math.min(config.max_year, currentYear + delta)) | ||||
|   if (newYear === currentYear) return | ||||
|  | ||||
|   const topDisplayIndex = Math.floor(viewportEl.value.scrollTop / rowHeight.value) | ||||
|   const currentWeekIndex = topDisplayIndex + minVirtualWeek.value | ||||
|  | ||||
|   navigateToYear(newYear, currentWeekIndex) | ||||
| } | ||||
|  | ||||
| const navigateToYear = (targetYear, weekIndex) => { | ||||
|   const monday = getMondayForVirtualWeek(weekIndex) | ||||
|   const { week } = isoWeekInfo(monday) | ||||
|   const jan4 = new Date(targetYear, 0, 4) | ||||
|   const jan4Monday = new Date(jan4) | ||||
|   jan4Monday.setDate(jan4.getDate() - mondayIndex(jan4)) | ||||
|   const targetMonday = new Date(jan4Monday) | ||||
|   targetMonday.setDate(jan4Monday.getDate() + (week - 1) * 7) | ||||
|   scrollToTarget(targetMonday) | ||||
| } | ||||
|  | ||||
| const scrollToTarget = (target) => { | ||||
|   let targetWeekIndex | ||||
|   if (target instanceof Date) { | ||||
|     targetWeekIndex = getWeekIndex(target) | ||||
|   } else { | ||||
|     targetWeekIndex = target | ||||
|   } | ||||
|  | ||||
|   const targetScrollTop = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value | ||||
|   viewportEl.value.scrollTop = targetScrollTop | ||||
|   updateVisibleWeeks() | ||||
| } | ||||
|  | ||||
| const goToTodayHandler = () => { | ||||
|   const today = new Date() | ||||
|   const top = new Date(today) | ||||
|   top.setDate(top.getDate() - 21) | ||||
|   scrollToTarget(top) | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   rowHeight.value = computeRowHeight() | ||||
|  | ||||
|   const minYearDate = new Date(config.min_year, 0, 1) | ||||
|   const maxYearLastDay = new Date(config.max_year, 11, 31) | ||||
|   const lastWeekMonday = new Date(maxYearLastDay) | ||||
|   lastWeekMonday.setDate(maxYearLastDay.getDate() - mondayIndex(maxYearLastDay)) | ||||
|  | ||||
|   minVirtualWeek.value = getWeekIndex(minYearDate) | ||||
|   const maxVirtualWeek = getWeekIndex(lastWeekMonday) | ||||
|   totalVirtualWeeks.value = maxVirtualWeek - minVirtualWeek.value + 1 | ||||
|  | ||||
|   const initialDate = fromLocalString(calendarStore.today) | ||||
|   scrollToTarget(initialDate) | ||||
|  | ||||
|   document.addEventListener('goToToday', goToTodayHandler) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   document.removeEventListener('goToToday', goToTodayHandler) | ||||
| }) | ||||
| </script> | ||||
| @@ -1,7 +1,16 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import { getLocalizedWeekdayNames, reorderByFirstDay, isoWeekInfo } from '@/utils/date' | ||||
| import { | ||||
|   getLocalizedWeekdayNames, | ||||
|   reorderByFirstDay, | ||||
|   getISOWeek, | ||||
|   getISOWeekYear, | ||||
|   MIN_YEAR, | ||||
|   MAX_YEAR, | ||||
| } from '@/utils/date' | ||||
| import Numeric from '@/components/Numeric.vue' | ||||
| import { addDays } from 'date-fns' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   scrollTop: { type: Number, default: 0 }, | ||||
| @@ -11,17 +20,64 @@ const props = defineProps({ | ||||
|  | ||||
| const calendarStore = useCalendarStore() | ||||
|  | ||||
| const yearLabel = computed(() => { | ||||
| // Emits year-change events | ||||
| const emit = defineEmits(['year-change']) | ||||
|  | ||||
| const baseDate = computed(() => new Date(1970, 0, 4 + calendarStore.config.first_day)) | ||||
| const WEEK_MS = 7 * 24 * 60 * 60 * 1000 | ||||
|  | ||||
| const topVirtualWeek = computed(() => { | ||||
|   const topDisplayIndex = Math.floor(props.scrollTop / props.rowHeight) | ||||
|   const topVW = topDisplayIndex + props.minVirtualWeek | ||||
|   const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day) | ||||
|   const firstDay = new Date(baseDate) | ||||
|   firstDay.setDate(firstDay.getDate() + topVW * 7) | ||||
|   return isoWeekInfo(firstDay).year | ||||
|   return topDisplayIndex + props.minVirtualWeek | ||||
| }) | ||||
|  | ||||
| const currentYear = computed(() => { | ||||
|   const weekStart = addDays(baseDate.value, topVirtualWeek.value * 7) | ||||
|   const anchor = addDays(weekStart, (4 - weekStart.getDay() + 7) % 7) | ||||
|   return getISOWeekYear(anchor) | ||||
| }) | ||||
|  | ||||
| function virtualWeekOf(d) { | ||||
|   const o = (d.getDay() - calendarStore.config.first_day + 7) % 7 | ||||
|   const fd = addDays(d, -o) | ||||
|   return Math.floor((fd.getTime() - baseDate.value.getTime()) / WEEK_MS) | ||||
| } | ||||
|  | ||||
| function isoWeekMonday(isoYear, isoWeek) { | ||||
|   const jan4 = new Date(isoYear, 0, 4) | ||||
|   const week1Mon = addDays(jan4, -((jan4.getDay() + 6) % 7)) | ||||
|   return addDays(week1Mon, (isoWeek - 1) * 7) | ||||
| } | ||||
|  | ||||
| function changeYear(y) { | ||||
|   if (y == null) return | ||||
|   y = Math.round(Math.max(MIN_YEAR, Math.min(MAX_YEAR, y))) | ||||
|   if (y === currentYear.value) return | ||||
|   const vw = topVirtualWeek.value | ||||
|   // Fraction within current row | ||||
|   const weekStartScroll = (vw - props.minVirtualWeek) * props.rowHeight | ||||
|   const frac = Math.max(0, Math.min(1, (props.scrollTop - weekStartScroll) / props.rowHeight)) | ||||
|   // Anchor Thursday of current calendar week | ||||
|   const curCalWeekStart = addDays(baseDate.value, vw * 7) | ||||
|   const curAnchorThu = addDays(curCalWeekStart, (4 - curCalWeekStart.getDay() + 7) % 7) | ||||
|   let isoW = getISOWeek(curAnchorThu) | ||||
|   // Build Monday of ISO week | ||||
|   let weekMon = isoWeekMonday(y, isoW) | ||||
|   if (getISOWeekYear(weekMon) !== y) { | ||||
|     isoW-- | ||||
|     weekMon = isoWeekMonday(y, isoW) | ||||
|   } | ||||
|   // Align to configured first day | ||||
|   const shift = (weekMon.getDay() - calendarStore.config.first_day + 7) % 7 | ||||
|   const calWeekStart = addDays(weekMon, -shift) | ||||
|   const targetVW = virtualWeekOf(calWeekStart) | ||||
|   let scrollTop = (targetVW - props.minVirtualWeek) * props.rowHeight + frac * props.rowHeight | ||||
|   if (Math.abs(scrollTop - props.scrollTop) < 0.5) scrollTop += 0.25 * props.rowHeight | ||||
|   emit('year-change', { year: y, scrollTop }) | ||||
| } | ||||
|  | ||||
| const weekdayNames = computed(() => { | ||||
|   // Get Monday-first names, then reorder by first day, then add weekend info | ||||
|   // Reorder names & weekend flags | ||||
|   const mondayFirstNames = getLocalizedWeekdayNames() | ||||
|   const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)] | ||||
|   const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day) | ||||
| @@ -36,7 +92,18 @@ const weekdayNames = computed(() => { | ||||
|  | ||||
| <template> | ||||
|   <div class="calendar-header"> | ||||
|     <div class="year-label">{{ yearLabel }}</div> | ||||
|     <div class="year-label"> | ||||
|       <Numeric | ||||
|         :model-value="currentYear" | ||||
|         @update:modelValue="changeYear" | ||||
|         :min="MIN_YEAR" | ||||
|         :max="MAX_YEAR" | ||||
|         :step="1" | ||||
|         aria-label="Year" | ||||
|         number-prefix="" | ||||
|         number-postfix="" | ||||
|       /> | ||||
|     </div> | ||||
|     <div | ||||
|       v-for="day in weekdayNames" | ||||
|       :key="day.name" | ||||
| @@ -52,7 +119,7 @@ const weekdayNames = computed(() => { | ||||
| <style scoped> | ||||
| .calendar-header { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem; | ||||
|   grid-template-columns: var(--week-w) repeat(7, 1fr) var(--month-w); | ||||
|   border-bottom: 2px solid var(--muted); | ||||
|   align-items: last baseline; | ||||
|   flex-shrink: 0; | ||||
| @@ -65,20 +132,11 @@ const weekdayNames = computed(() => { | ||||
|   -webkit-touch-callout: none; | ||||
|   -webkit-tap-highlight-color: transparent; | ||||
| } | ||||
|  | ||||
| .year-label { | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   width: 100%; | ||||
|   color: var(--muted); | ||||
|   font-size: 1.2em; | ||||
|   padding: 0.5rem; | ||||
| } | ||||
| .dow { | ||||
|   text-transform: uppercase; | ||||
|   text-align: center; | ||||
|   padding: 0.5rem; | ||||
|   font-weight: 500; | ||||
|   font-weight: 600; | ||||
|   font-size: 1.2em; | ||||
| } | ||||
| .dow.weekend { | ||||
|   color: var(--weekend); | ||||
|   | ||||
| @@ -1,235 +1,172 @@ | ||||
| <script setup> | ||||
| import { ref, onMounted, onBeforeUnmount, computed } from 'vue' | ||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import CalendarHeader from '@/components/CalendarHeader.vue' | ||||
| import CalendarWeek from '@/components/CalendarWeek.vue' | ||||
| import Jogwheel from '@/components/Jogwheel.vue' | ||||
| import HeaderControls from '@/components/HeaderControls.vue' | ||||
| import { | ||||
|   isoWeekInfo, | ||||
|   getLocalizedMonthName, | ||||
|   monthAbbr, | ||||
|   lunarPhaseSymbol, | ||||
|   pad, | ||||
|   daysInclusive, | ||||
|   addDaysStr, | ||||
|   formatDateRange, | ||||
| } from '@/utils/date' | ||||
| import { toLocalString, fromLocalString } from '@/utils/date' | ||||
|   createScrollManager, | ||||
|   createWeekColumnScrollManager, | ||||
|   createMonthScrollManager, | ||||
| } from '@/plugins/scrollManager' | ||||
| import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date' | ||||
| import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date' | ||||
| 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 scrollTop = ref(0) | ||||
| const viewport = ref(null) | ||||
| const viewportHeight = ref(600) | ||||
| const rowHeight = ref(64) | ||||
| const baseDate = new Date(1970, 0, 4 + calendarStore.config.first_day) | ||||
|  | ||||
| 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) | ||||
| 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') { | ||||
|     if (val.date) return String(val.date) | ||||
|     if (val.startDate) return String(val.startDate) | ||||
|   } | ||||
|   return String(val) | ||||
| } | ||||
|  | ||||
| const WEEK_MS = 7 * 24 * 60 * 60 * 1000 | ||||
| function registerTap(rawDate, type) { | ||||
|   const dateStr = normalizeDate(rawDate) | ||||
|   const now = Date.now() | ||||
|   const prev = pendingTap.value | ||||
|   const delta = now - prev.time | ||||
|   const isDouble = | ||||
|     prev.date === dateStr && prev.type === type && delta <= DOUBLE_TAP_DELAY && delta >= 35 | ||||
|   if (isDouble) { | ||||
|     pendingTap.value = { date: null, time: 0, type: null } | ||||
|     return true | ||||
|   } | ||||
|   pendingTap.value = { date: dateStr, time: now, type } | ||||
|   return false | ||||
| } | ||||
|  | ||||
| const minVirtualWeek = computed(() => { | ||||
|   const date = new Date(calendarStore.minYear, 0, 1) | ||||
|   const firstDayOfWeek = new Date(date) | ||||
|   const date = new Date(MIN_YEAR, 0, 1) | ||||
|   const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 | ||||
|   firstDayOfWeek.setDate(date.getDate() - dayOffset) | ||||
|   return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) | ||||
|   const firstDayOfWeek = addDays(date, -dayOffset) | ||||
|   return differenceInWeeks(firstDayOfWeek, baseDate.value) | ||||
| }) | ||||
|  | ||||
| const maxVirtualWeek = computed(() => { | ||||
|   const date = new Date(calendarStore.maxYear, 11, 31) | ||||
|   const firstDayOfWeek = new Date(date) | ||||
|   const date = new Date(MAX_YEAR, 11, 31) | ||||
|   const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 | ||||
|   firstDayOfWeek.setDate(date.getDate() - dayOffset) | ||||
|   return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) | ||||
|   const firstDayOfWeek = addDays(date, -dayOffset) | ||||
|   return differenceInWeeks(firstDayOfWeek, baseDate.value) | ||||
| }) | ||||
|  | ||||
| const totalVirtualWeeks = computed(() => { | ||||
|   return maxVirtualWeek.value - minVirtualWeek.value + 1 | ||||
| }) | ||||
|  | ||||
| const initialScrollTop = computed(() => { | ||||
|   const targetWeekIndex = getWeekIndex(calendarStore.now) - 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), | ||||
|   ) | ||||
| }) | ||||
|  | ||||
| const todayString = computed(() => { | ||||
|   const t = calendarStore.now | ||||
|     .toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) | ||||
|     .replace(/,? /, '\n') | ||||
|   return t.charAt(0).toUpperCase() + t.slice(1) | ||||
| }) | ||||
|  | ||||
| const visibleWeeks = computed(() => { | ||||
|   const buffer = 10 | ||||
|   const startIdx = Math.floor((scrollTop.value - buffer * rowHeight.value) / rowHeight.value) | ||||
|   const endIdx = Math.ceil( | ||||
|     (scrollTop.value + 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)) | ||||
|   } | ||||
|   return weeks | ||||
| }) | ||||
|  | ||||
| const contentHeight = computed(() => { | ||||
|   return totalVirtualWeeks.value * rowHeight.value | ||||
| }) | ||||
|  | ||||
| // 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, refreshEvents } = 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 | ||||
| }) | ||||
|  | ||||
| function computeRowHeight() { | ||||
|   if (rowProbe.value) { | ||||
|     const h = rowProbe.value.getBoundingClientRect().height || 64 | ||||
|     rowHeight.value = Math.round(h) | ||||
|     return rowHeight.value | ||||
|   } | ||||
|   const el = document.createElement('div') | ||||
|   el.style.position = 'absolute' | ||||
|   el.style.visibility = 'hidden' | ||||
|   el.style.height = 'var(--cell-h)' | ||||
|   el.style.height = 'var(--row-h)' | ||||
|   document.body.appendChild(el) | ||||
|   const h = el.getBoundingClientRect().height || 64 | ||||
|   el.remove() | ||||
|   rowHeight.value = Math.round(h) | ||||
|   return rowHeight.value | ||||
| } | ||||
|  | ||||
| function getWeekIndex(date) { | ||||
|   const firstDayOfWeek = new Date(date) | ||||
|   const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 | ||||
|   firstDayOfWeek.setDate(date.getDate() - dayOffset) | ||||
|   return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) | ||||
| } | ||||
|  | ||||
| function getFirstDayForVirtualWeek(virtualWeek) { | ||||
|   const firstDay = new Date(baseDate) | ||||
|   firstDay.setDate(firstDay.getDate() + virtualWeek * 7) | ||||
|   return firstDay | ||||
| } | ||||
|  | ||||
| function createWeek(virtualWeek) { | ||||
|   const firstDay = getFirstDayForVirtualWeek(virtualWeek) | ||||
|   const weekNumber = isoWeekInfo(firstDay).week | ||||
|   const days = [] | ||||
|   const cur = new Date(firstDay) | ||||
|   let hasFirst = false | ||||
|   let monthToLabel = null | ||||
|   let labelYear = null | ||||
|  | ||||
|   for (let i = 0; i < 7; i++) { | ||||
|     const dateStr = toLocalString(cur) | ||||
|     const eventsForDay = calendarStore.events.get(dateStr) || [] | ||||
|     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() | ||||
| function measureFromProbe() { | ||||
|   if (!rowProbe.value) return | ||||
|   const h = rowProbe.value.getBoundingClientRect().height | ||||
|   if (!h) return | ||||
|   const newH = Math.round(h) | ||||
|   if (newH !== rowHeight.value) { | ||||
|     const oldH = rowHeight.value | ||||
|     // Anchor: keep the same top virtual week visible. | ||||
|     const topVirtualWeek = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value | ||||
|     rowHeight.value = newH | ||||
|     const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH | ||||
|     setScrollTop(newScrollTop, 'row-height-change') | ||||
|     resetWeeks('row-height-change') | ||||
|   } | ||||
| } | ||||
|  | ||||
|     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), | ||||
|       isSelected: | ||||
|         selection.value.startDate && | ||||
|         selection.value.dayCount > 0 && | ||||
|         dateStr >= selection.value.startDate && | ||||
|         dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1), | ||||
|       events: eventsForDay, | ||||
|     }) | ||||
|     cur.setDate(cur.getDate() + 1) | ||||
|   } | ||||
| const { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm | ||||
|  | ||||
|   let monthLabel = null | ||||
|   if (hasFirst && monthToLabel !== null) { | ||||
|     if (labelYear && labelYear <= calendarStore.config.max_year) { | ||||
|       let weeksSpan = 0 | ||||
|       const d = new Date(cur) | ||||
|       d.setDate(cur.getDate() - 1) | ||||
| // createWeek logic moved to virtualWeeks plugin | ||||
|  | ||||
|       for (let i = 0; i < 6; i++) { | ||||
|         d.setDate(cur.getDate() - 1 + i * 7) | ||||
|         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, | ||||
|         height: weeksSpan * rowHeight.value, | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     virtualWeek, | ||||
|     weekNumber: pad(weekNumber), | ||||
|     days, | ||||
|     monthLabel, | ||||
|     top: (virtualWeek - minVirtualWeek.value) * rowHeight.value, | ||||
|   } | ||||
| } | ||||
|  | ||||
| function goToToday() { | ||||
|   const top = new Date(calendarStore.now) | ||||
|   top.setDate(top.getDate() - 21) | ||||
|   const targetWeekIndex = getWeekIndex(top) | ||||
|   scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value | ||||
|   if (viewport.value) { | ||||
|     viewport.value.scrollTop = scrollTop.value | ||||
|   } | ||||
| } | ||||
| // goToToday now provided by manager | ||||
|  | ||||
| function clearSelection() { | ||||
|   selection.value = { startDate: null, dayCount: 0 } | ||||
| } | ||||
|  | ||||
| function startDrag(dateStr) { | ||||
|   dateStr = normalizeDate(dateStr) | ||||
|   if (calendarStore.config.select_days === 0) return | ||||
|   isDragging.value = true | ||||
|   dragAnchor.value = dateStr | ||||
|   selection.value = { startDate: dateStr, dayCount: 1 } | ||||
|   addGlobalTouchListeners() | ||||
| } | ||||
|  | ||||
| function updateDrag(dateStr) { | ||||
| @@ -245,10 +182,102 @@ function endDrag(dateStr) { | ||||
|   selection.value = { startDate, dayCount } | ||||
| } | ||||
|  | ||||
| function finalizeDragAndCreate() { | ||||
|   if (!isDragging.value) return | ||||
|   isDragging.value = false | ||||
|   const eventData = createEventFromSelection() | ||||
|   if (eventData) { | ||||
|     clearSelection() | ||||
|     emit('create-event', eventData) | ||||
|   } | ||||
|   removeGlobalTouchListeners() | ||||
| } | ||||
|  | ||||
| // Build a minimal event creation payload from current selection | ||||
| // Returns null if selection is invalid or empty. | ||||
| function createEventFromSelection() { | ||||
|   const sel = selection.value || {} | ||||
|   if (!sel.startDate || !sel.dayCount || sel.dayCount <= 0) return null | ||||
|   return { | ||||
|     startDate: sel.startDate, | ||||
|     dayCount: sel.dayCount, | ||||
|   } | ||||
| } | ||||
|  | ||||
| function getDateUnderPoint(x, y) { | ||||
|   const el = document.elementFromPoint(x, y) | ||||
|   let cur = el | ||||
|   while (cur) { | ||||
|     if (cur.dataset && cur.dataset.date) return cur.dataset.date | ||||
|     cur = cur.parentElement | ||||
|   } | ||||
|   return getDateFromCoordinates(x, y) | ||||
| } | ||||
|  | ||||
| function onGlobalTouchMove(e) { | ||||
|   if (!isDragging.value) return | ||||
|   const t = e.touches && e.touches[0] | ||||
|   if (!t) return | ||||
|   e.preventDefault() | ||||
|   const dateStr = getDateUnderPoint(t.clientX, t.clientY) | ||||
|   if (dateStr) updateDrag(dateStr) | ||||
| } | ||||
|  | ||||
| function onGlobalTouchEnd(e) { | ||||
|   if (!isDragging.value) { | ||||
|     removeGlobalTouchListeners() | ||||
|     return | ||||
|   } | ||||
|   const t = (e.changedTouches && e.changedTouches[0]) || (e.touches && e.touches[0]) | ||||
|   if (t) { | ||||
|     const dateStr = getDateUnderPoint(t.clientX, t.clientY) | ||||
|     if (dateStr) { | ||||
|       const { startDate, dayCount } = calculateSelection(dragAnchor.value, dateStr) | ||||
|       selection.value = { startDate, dayCount } | ||||
|     } | ||||
|   } | ||||
|   finalizeDragAndCreate() | ||||
| } | ||||
|  | ||||
| function addGlobalTouchListeners() { | ||||
|   window.addEventListener('touchmove', onGlobalTouchMove, { passive: false }) | ||||
|   window.addEventListener('touchend', onGlobalTouchEnd, { passive: false }) | ||||
|   window.addEventListener('touchcancel', onGlobalTouchEnd, { passive: false }) | ||||
| } | ||||
|  | ||||
| function removeGlobalTouchListeners() { | ||||
|   window.removeEventListener('touchmove', onGlobalTouchMove) | ||||
|   window.removeEventListener('touchend', onGlobalTouchEnd) | ||||
|   window.removeEventListener('touchcancel', onGlobalTouchEnd) | ||||
| } | ||||
|  | ||||
| // Fallback hit-test if elementFromPoint doesn't find a day cell (e.g., moving between rows). | ||||
| function getDateFromCoordinates(clientX, clientY) { | ||||
|   if (!viewport.value) return null | ||||
|   const vpRect = viewport.value.getBoundingClientRect() | ||||
|   const yOffset = clientY - vpRect.top + viewport.value.scrollTop | ||||
|   if (yOffset < 0) return null | ||||
|   const rowIndex = Math.floor(yOffset / rowHeight.value) | ||||
|   const virtualWeek = minVirtualWeek.value + rowIndex | ||||
|   if (virtualWeek < minVirtualWeek.value || virtualWeek > maxVirtualWeek.value) return null | ||||
|   const sampleWeek = viewport.value.querySelector('.week-row') | ||||
|   if (!sampleWeek) return null | ||||
|   const labelEl = sampleWeek.querySelector('.week-label') | ||||
|   const wrRect = sampleWeek.getBoundingClientRect() | ||||
|   const labelRight = labelEl ? labelEl.getBoundingClientRect().right : wrRect.left | ||||
|   const daysAreaRight = wrRect.right | ||||
|   const daysWidth = daysAreaRight - labelRight | ||||
|   if (clientX < labelRight || clientX > daysAreaRight) return null | ||||
|   const col = Math.min(6, Math.max(0, Math.floor(((clientX - labelRight) / daysWidth) * 7))) | ||||
|   const firstDay = getFirstDayForVirtualWeek(virtualWeek) | ||||
|   const targetDate = addDays(firstDay, col) | ||||
|   return toLocalString(targetDate, DEFAULT_TZ) | ||||
| } | ||||
|  | ||||
| function calculateSelection(anchorStr, otherStr) { | ||||
|   const limit = calendarStore.config.select_days | ||||
|   const anchorDate = fromLocalString(anchorStr) | ||||
|   const otherDate = fromLocalString(otherStr) | ||||
|   const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ) | ||||
|   const otherDate = fromLocalString(otherStr, DEFAULT_TZ) | ||||
|   const forward = otherDate >= anchorDate | ||||
|   const span = daysInclusive(anchorStr, otherStr) | ||||
|  | ||||
| @@ -260,21 +289,18 @@ function calculateSelection(anchorStr, otherStr) { | ||||
|   if (forward) { | ||||
|     return { startDate: anchorStr, dayCount: limit } | ||||
|   } else { | ||||
|     const startDate = addDaysStr(anchorStr, -(limit - 1)) | ||||
|     const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ) | ||||
|     return { startDate, dayCount: limit } | ||||
|   } | ||||
| } | ||||
|  | ||||
| const onScroll = () => { | ||||
|   if (viewport.value) { | ||||
|     scrollTop.value = viewport.value.scrollTop | ||||
|   } | ||||
| } | ||||
|  | ||||
| const handleJogwheelScrollTo = (newScrollTop) => { | ||||
|   if (viewport.value) { | ||||
|     viewport.value.scrollTop = newScrollTop | ||||
|   } | ||||
| // ---------------- Week label column drag scrolling ---------------- | ||||
| function getWeekLabelRect() { | ||||
|   // Prefer header year label width as stable reference | ||||
|   const headerYear = document.querySelector('.calendar-header .year-label') | ||||
|   if (headerYear) return headerYear.getBoundingClientRect() | ||||
|   const weekLabel = viewport.value?.querySelector('.week-row .week-label') | ||||
|   return weekLabel ? weekLabel.getBoundingClientRect() : null | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| @@ -283,14 +309,27 @@ onMounted(() => { | ||||
|  | ||||
|   if (viewport.value) { | ||||
|     viewportHeight.value = viewport.value.clientHeight | ||||
|     viewport.value.scrollTop = initialScrollTop.value | ||||
|     setScrollTop(initialScrollTop.value, 'initial-mount') | ||||
|     viewport.value.addEventListener('scroll', onScroll) | ||||
|     // Capture mousedown in viewport to allow dragging via week label column | ||||
|     viewport.value.addEventListener('mousedown', handleWeekColMouseDown, true) | ||||
|   } | ||||
|   document.addEventListener('pointerlockchange', handlePointerLockChange) | ||||
|  | ||||
|   const timer = setInterval(() => { | ||||
|     calendarStore.updateCurrentDate() | ||||
|   }, 60000) | ||||
|  | ||||
|   // Initial incremental build (no existing weeks yet) | ||||
|   scheduleWindowUpdate('init') | ||||
|  | ||||
|   if (window.ResizeObserver && rowProbe.value) { | ||||
|     rowProbeObserver = new ResizeObserver(() => { | ||||
|       measureFromProbe() | ||||
|     }) | ||||
|     rowProbeObserver.observe(rowProbe.value) | ||||
|   } | ||||
|  | ||||
|   onBeforeUnmount(() => { | ||||
|     clearInterval(timer) | ||||
|   }) | ||||
| @@ -299,113 +338,157 @@ onMounted(() => { | ||||
| onBeforeUnmount(() => { | ||||
|   if (viewport.value) { | ||||
|     viewport.value.removeEventListener('scroll', onScroll) | ||||
|     viewport.value.removeEventListener('mousedown', handleWeekColMouseDown, true) | ||||
|   } | ||||
|   if (rowProbeObserver && rowProbe.value) { | ||||
|     try { | ||||
|       rowProbeObserver.unobserve(rowProbe.value) | ||||
|       rowProbeObserver.disconnect() | ||||
|     } catch (e) {} | ||||
|   } | ||||
|   document.removeEventListener('pointerlockchange', handlePointerLockChange) | ||||
| }) | ||||
|  | ||||
| const handleDayMouseDown = (dateStr) => { | ||||
|   startDrag(dateStr) | ||||
| const handleDayMouseDown = (d) => { | ||||
|   d = normalizeDate(d) | ||||
|   if (Date.now() < suppressMouseUntil.value) return | ||||
|   if (registerTap(d, 'mouse')) startDrag(d) | ||||
| } | ||||
|  | ||||
| const handleDayMouseEnter = (dateStr) => { | ||||
|   if (isDragging.value) { | ||||
|     updateDrag(dateStr) | ||||
|   } | ||||
| } | ||||
|  | ||||
| const handleDayMouseUp = (dateStr) => { | ||||
|   if (isDragging.value) { | ||||
|     endDrag(dateStr) | ||||
|     const eventData = createEventFromSelection() | ||||
|     if (eventData) { | ||||
| const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d)) | ||||
| const handleDayMouseUp = (d) => { | ||||
|   d = normalizeDate(d) | ||||
|   if (Date.now() < suppressMouseUntil.value && !isDragging.value) return | ||||
|   if (!isDragging.value) return | ||||
|   endDrag(d) | ||||
|   const ev = createEventFromSelection() | ||||
|   if (ev) { | ||||
|     clearSelection() | ||||
|       emit('create-event', eventData) | ||||
|     emit('create-event', ev) | ||||
|   } | ||||
| } | ||||
| const handleDayTouchStart = (d) => { | ||||
|   d = normalizeDate(d) | ||||
|   suppressMouseUntil.value = Date.now() + 800 | ||||
|   if (registerTap(d, 'touch')) startDrag(d) | ||||
| } | ||||
|  | ||||
| const handleDayTouchStart = (dateStr) => { | ||||
|   startDrag(dateStr) | ||||
| const handleEventClick = (payload) => { | ||||
|   emit('edit-event', payload) | ||||
| } | ||||
|  | ||||
| const handleDayTouchMove = (dateStr) => { | ||||
|   if (isDragging.value) { | ||||
|     updateDrag(dateStr) | ||||
|   } | ||||
| // 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 | ||||
|   return /\p{Script=Latin}/u.test(label) | ||||
| } | ||||
|  | ||||
| const handleDayTouchEnd = (dateStr) => { | ||||
|   if (isDragging.value) { | ||||
|     endDrag(dateStr) | ||||
|     const eventData = createEventFromSelection() | ||||
|     if (eventData) { | ||||
|       clearSelection() | ||||
|       emit('create-event', eventData) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| // Watch first day changes (e.g., first_day config update) to adjust scroll | ||||
| // Keep roughly same visible date when first_day setting changes. | ||||
| watch( | ||||
|   () => calendarStore.config.first_day, | ||||
|   () => { | ||||
|     const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value | ||||
|     const currentTopDate = getFirstDayForVirtualWeek(currentTopVW) | ||||
|     requestAnimationFrame(() => { | ||||
|       const newTopWeekIndex = getWeekIndex(currentTopDate) | ||||
|       const newScroll = (newTopWeekIndex - minVirtualWeek.value) * rowHeight.value | ||||
|       setScrollTop(newScroll, 'first-day-change') | ||||
|       resetWeeks('first-day-change') | ||||
|     }) | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| const handleEventClick = (eventInstanceId) => { | ||||
|   emit('edit-event', eventInstanceId) | ||||
| } | ||||
| // Watch lightweight mutation counter only (not deep events map) and rebuild lazily | ||||
| watch( | ||||
|   () => calendarStore.events, | ||||
|   () => { | ||||
|     refreshEvents('events') | ||||
|   }, | ||||
|   { deep: true }, | ||||
| ) | ||||
|  | ||||
| // Reflect selection & events by rebuilding day objects in-place | ||||
| watch( | ||||
|   () => [selection.value.startDate, selection.value.dayCount], | ||||
|   () => refreshEvents('selection'), | ||||
| ) | ||||
|  | ||||
| // Rebuild if viewport height changes (e.g., resize) | ||||
| window.addEventListener('resize', () => { | ||||
|   if (viewport.value) viewportHeight.value = viewport.value.clientHeight | ||||
|   measureFromProbe() | ||||
|   scheduleWindowUpdate('resize') | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div class="calendar-view-root"> | ||||
|     <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> | ||||
|     <div class="wrap"> | ||||
|     <header> | ||||
|       <h1>Calendar</h1> | ||||
|       <div class="header-controls"> | ||||
|         <div class="today-date" @click="goToToday">{{ todayString }}</div> | ||||
|       </div> | ||||
|     </header> | ||||
|       <HeaderControls @go-to-today="goToToday" /> | ||||
|       <CalendarHeader | ||||
|         :scroll-top="scrollTop" | ||||
|         :row-height="rowHeight" | ||||
|         :min-virtual-week="minVirtualWeek" | ||||
|         @year-change="handleHeaderYearChange" | ||||
|       /> | ||||
|       <div class="calendar-container"> | ||||
|         <div class="calendar-viewport" ref="viewport"> | ||||
|           <!-- Main calendar content (weeks and days) --> | ||||
|           <div class="main-calendar-area"> | ||||
|             <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||
|               <CalendarWeek | ||||
|                 v-for="week in visibleWeeks" | ||||
|                 :key="week.virtualWeek" | ||||
|                 :week="week" | ||||
|                 :dragging="isDragging" | ||||
|                 :style="{ top: week.top + 'px' }" | ||||
|                 @day-mousedown="handleDayMouseDown" | ||||
|                 @day-mouseenter="handleDayMouseEnter" | ||||
|                 @day-mouseup="handleDayMouseUp" | ||||
|                 @day-touchstart="handleDayTouchStart" | ||||
|             @day-touchmove="handleDayTouchMove" | ||||
|             @day-touchend="handleDayTouchEnd" | ||||
|                 @event-click="handleEventClick" | ||||
|               /> | ||||
|           <!-- Month labels positioned absolutely --> | ||||
|             </div> | ||||
|           </div> | ||||
|           <!-- Month column area --> | ||||
|           <div class="month-column-area"> | ||||
|             <!-- Month labels --> | ||||
|             <div class="month-labels-container" :style="{ height: contentHeight + 'px' }"> | ||||
|               <template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> | ||||
|                 <div | ||||
|             v-for="week in visibleWeeks" | ||||
|             :key="`month-${week.virtualWeek}`" | ||||
|             v-show="week.monthLabel" | ||||
|             class="month-name-label" | ||||
|                   v-if="monthWeek && monthWeek.monthLabel" | ||||
|                   class="month-label" | ||||
|                   :class="monthWeek.monthLabel?.monthClass" | ||||
|                   :style="{ | ||||
|               top: week.top + 'px', | ||||
|               height: week.monthLabel?.height + 'px', | ||||
|                     height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`, | ||||
|                     top: (monthWeek.top || 0) + 'px', | ||||
|                   }" | ||||
|                   @pointerdown="handleMonthScrollPointerDown" | ||||
|                   @touchstart.prevent="handleMonthScrollTouchStart" | ||||
|                   @wheel="handleMonthScrollWheel" | ||||
|                 > | ||||
|             <span>{{ week.monthLabel?.text }}</span> | ||||
|                   <span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{ | ||||
|                     monthWeek.monthLabel?.text || '' | ||||
|                   }}</span> | ||||
|                 </div> | ||||
|               </template> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <!-- Jogwheel as sibling to calendar-viewport --> | ||||
|       <Jogwheel | ||||
|         :total-virtual-weeks="totalVirtualWeeks" | ||||
|         :row-height="rowHeight" | ||||
|         :viewport-height="viewportHeight" | ||||
|         :scroll-top="scrollTop" | ||||
|         @scroll-to="handleJogwheelScrollTo" | ||||
|       /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| .calendar-view-root { | ||||
|   display: contents; | ||||
| } | ||||
| .wrap { | ||||
|   height: 100vh; | ||||
|   display: flex; | ||||
| @@ -414,33 +497,15 @@ const handleEventClick = (eventInstanceId) => { | ||||
|  | ||||
| header { | ||||
|   display: flex; | ||||
|   justify-content: space-between; | ||||
|   align-items: center; | ||||
|   gap: 1.25rem; | ||||
|   padding: 0.75rem 0.5rem 0.25rem 0.5rem; | ||||
| } | ||||
|  | ||||
| header h1 { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
| } | ||||
| .header-controls { | ||||
|   display: flex; | ||||
|   gap: 1rem; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .today-date { | ||||
|   cursor: pointer; | ||||
|   padding: 0.5rem; | ||||
|   background: var(--today-btn-bg); | ||||
|   color: var(--today-btn-text); | ||||
|   border-radius: 4px; | ||||
|   white-space: pre-line; | ||||
|   text-align: center; | ||||
|   font-size: 0.9rem; | ||||
| } | ||||
|  | ||||
| .today-date:hover { | ||||
|   background: var(--today-btn-hover-bg); | ||||
|   font-size: 1.6rem; | ||||
|   font-weight: 600; | ||||
| } | ||||
|  | ||||
| .calendar-container { | ||||
| @@ -460,7 +525,13 @@ header h1 { | ||||
|   flex: 1; | ||||
|   overflow-y: auto; | ||||
|   overflow-x: hidden; | ||||
|   display: grid; | ||||
|   grid-template-columns: 1fr var(--month-w); | ||||
| } | ||||
|  | ||||
| .main-calendar-area { | ||||
|   position: relative; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .calendar-content { | ||||
| @@ -468,27 +539,52 @@ header h1 { | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .month-name-label { | ||||
| .month-column-area { | ||||
|   position: relative; | ||||
|   cursor: ns-resize; | ||||
| } | ||||
|  | ||||
| .month-labels-container { | ||||
|   position: relative; | ||||
|   width: 100%; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| .month-label { | ||||
|   position: absolute; | ||||
|   right: 0; | ||||
|   width: 3rem; /* Match jogwheel width */ | ||||
|   left: 0; | ||||
|   width: 100%; | ||||
|   background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2)); | ||||
|   font-size: 2em; | ||||
|   font-weight: 700; | ||||
|   color: var(--muted); | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   pointer-events: none; | ||||
|   z-index: 15; | ||||
|   overflow: visible; | ||||
|   overflow: hidden; | ||||
|   cursor: ns-resize; | ||||
|   user-select: none; | ||||
|   touch-action: none; | ||||
| } | ||||
|  | ||||
| .month-name-label > span { | ||||
| .month-label > span { | ||||
|   display: inline-block; | ||||
|   white-space: nowrap; | ||||
|   writing-mode: vertical-rl; | ||||
|   text-orientation: mixed; | ||||
|   transform: rotate(180deg); | ||||
|   transform-origin: center; | ||||
|   pointer-events: none; | ||||
| } | ||||
|  | ||||
| .bottomup { | ||||
|   transform: rotate(180deg); | ||||
| } | ||||
|  | ||||
| .row-height-probe { | ||||
|   position: absolute; | ||||
|   visibility: hidden; | ||||
|   height: var(--row-h); | ||||
|   pointer-events: none; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -2,11 +2,15 @@ | ||||
| import CalendarDay from './CalendarDay.vue' | ||||
| import EventOverlay from './EventOverlay.vue' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   week: Object | ||||
| }) | ||||
| const props = defineProps({ week: Object, dragging: { type: Boolean, default: false } }) | ||||
|  | ||||
| const emit = defineEmits(['day-mousedown', 'day-mouseenter', 'day-mouseup', 'day-touchstart', 'day-touchmove', 'day-touchend', 'event-click']) | ||||
| const emit = defineEmits([ | ||||
|   'day-mousedown', | ||||
|   'day-mouseenter', | ||||
|   'day-mouseup', | ||||
|   'day-touchstart', | ||||
|   'event-click', | ||||
| ]) | ||||
|  | ||||
| const handleDayMouseDown = (dateStr) => { | ||||
|   emit('day-mousedown', dateStr) | ||||
| @@ -24,42 +28,38 @@ const handleDayTouchStart = (dateStr) => { | ||||
|   emit('day-touchstart', dateStr) | ||||
| } | ||||
|  | ||||
| const handleDayTouchMove = (dateStr) => { | ||||
|   emit('day-touchmove', dateStr) | ||||
| // touchmove & touchend handled globally in CalendarView | ||||
|  | ||||
| const handleEventClick = (payload) => { | ||||
|   emit('event-click', payload) | ||||
| } | ||||
|  | ||||
| const handleDayTouchEnd = (dateStr) => { | ||||
|   emit('day-touchend', dateStr) | ||||
| // Only apply upside-down rotation (bottomup) for Latin script month labels | ||||
| 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) | ||||
|   } | ||||
|  | ||||
| const handleEventClick = (eventId) => { | ||||
|   emit('event-click', eventId) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <div  | ||||
|     class="week-row"  | ||||
|     :style="{ top: `${props.week.top}px` }" | ||||
|   > | ||||
|   <div class="week-row" :style="{ top: `${props.week.top}px` }"> | ||||
|     <div class="week-label">W{{ props.week.weekNumber }}</div> | ||||
|     <div class="days-grid"> | ||||
|       <CalendarDay | ||||
|         v-for="day in props.week.days" | ||||
|         :key="day.date" | ||||
|         :day="day" | ||||
|         :dragging="props.dragging" | ||||
|         @mousedown="handleDayMouseDown(day.date)" | ||||
|         @mouseenter="handleDayMouseEnter(day.date)" | ||||
|         @mouseup="handleDayMouseUp(day.date)" | ||||
|         @touchstart="handleDayTouchStart(day.date)" | ||||
|         @touchmove="handleDayTouchMove(day.date)" | ||||
|         @touchend="handleDayTouchEnd(day.date)" | ||||
|         @event-click="handleEventClick" | ||||
|       /> | ||||
|       <EventOverlay  | ||||
|         :week="props.week"  | ||||
|         @event-click="handleEventClick" | ||||
|       /> | ||||
|       <EventOverlay :week="props.week" @event-click="handleEventClick" /> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -67,9 +67,9 @@ const handleEventClick = (eventId) => { | ||||
| <style scoped> | ||||
| .week-row { | ||||
|   display: grid; | ||||
|   grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem; | ||||
|   grid-template-columns: var(--week-w) repeat(7, 1fr); | ||||
|   position: absolute; | ||||
|   height: var(--cell-h); | ||||
|   height: var(--row-h); | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| @@ -80,13 +80,8 @@ const handleEventClick = (eventId) => { | ||||
|   color: var(--muted); | ||||
|   font-size: 1.2em; | ||||
|   font-weight: 500; | ||||
|   /* Prevent text selection */ | ||||
|   -webkit-user-select: none; | ||||
|   -moz-user-select: none; | ||||
|   -ms-user-select: none; | ||||
|   user-select: none; | ||||
|   -webkit-touch-callout: none; | ||||
|   -webkit-tap-highlight-color: transparent; | ||||
|   height: var(--row-h); | ||||
| } | ||||
|  | ||||
| .days-grid { | ||||
| @@ -96,10 +91,4 @@ const handleEventClick = (eventId) => { | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| /* Fixed heights for cells and labels (from cells.css) */ | ||||
| .week-row :deep(.cell),  | ||||
| .week-label {  | ||||
|   height: var(--cell-h);  | ||||
| } | ||||
| </style> | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -5,6 +5,8 @@ | ||||
|       :key="span.id" | ||||
|       class="event-span" | ||||
|       :class="[`event-color-${span.colorId}`]" | ||||
|       :data-id="span.id" | ||||
|       :data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0" | ||||
|       :style="{ | ||||
|         gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, | ||||
|         gridRow: `${span.row}`, | ||||
| @@ -24,174 +26,104 @@ | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date' | ||||
| import { daysInclusive, addDaysStr } from '@/utils/date' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   week: { | ||||
|     type: Object, | ||||
|     required: true, | ||||
|   }, | ||||
|   week: { type: Object, required: true }, | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['event-click']) | ||||
| const store = useCalendarStore() | ||||
|  | ||||
| // Local drag state | ||||
| // Drag state | ||||
| const dragState = ref(null) | ||||
| const justDragged = ref(false) | ||||
|  | ||||
| // Generate repeat occurrences for a specific date | ||||
| function generateRepeatOccurrencesForDate(targetDateStr) { | ||||
|   const occurrences = [] | ||||
|  | ||||
|   // Get all events from the store and check for repeating ones | ||||
|   for (const [, eventList] of store.events) { | ||||
|     for (const baseEvent of eventList) { | ||||
|       if (!baseEvent.isRepeating || baseEvent.repeat === 'none') { | ||||
|         continue | ||||
|       } | ||||
|  | ||||
|       const targetDate = new Date(fromLocalString(targetDateStr)) | ||||
|       const baseStartDate = new Date(fromLocalString(baseEvent.startDate)) | ||||
|       const baseEndDate = new Date(fromLocalString(baseEvent.endDate)) | ||||
|       const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000)) | ||||
|  | ||||
|       if (baseEvent.repeat === 'weeks') { | ||||
|         const repeatWeekdays = baseEvent.repeatWeekdays | ||||
|         if (targetDate < baseStartDate) continue | ||||
|         const maxOccurrences = | ||||
|           baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) | ||||
|         if (maxOccurrences === 0) continue | ||||
|         const interval = baseEvent.repeatInterval || 1 | ||||
|         const msPerDay = 24 * 60 * 60 * 1000 | ||||
|  | ||||
|         // Determine if targetDate lies within some occurrence span. We look backwards up to spanDays to find a start day. | ||||
|         let occStart = null | ||||
|         for (let back = 0; back <= spanDays; back++) { | ||||
|           const cand = new Date(targetDate) | ||||
|           cand.setDate(cand.getDate() - back) | ||||
|           if (cand < baseStartDate) break | ||||
|           const daysDiff = Math.floor((cand - baseStartDate) / msPerDay) | ||||
|           const weeksDiff = Math.floor(daysDiff / 7) | ||||
|           if (weeksDiff % interval !== 0) continue | ||||
|           if (repeatWeekdays[cand.getDay()]) { | ||||
|             // candidate start must produce span covering targetDate | ||||
|             const candEnd = new Date(cand) | ||||
|             candEnd.setDate(candEnd.getDate() + spanDays) | ||||
|             if (targetDate <= candEnd) { | ||||
|               occStart = cand | ||||
|               break | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|         if (!occStart) continue | ||||
|         // Skip base occurrence if this is within its span (base already physically stored) | ||||
|         if (occStart.getTime() === baseStartDate.getTime()) continue | ||||
|         // Compute occurrence index (number of previous start days) | ||||
|         let occIdx = 0 | ||||
|         const cursor = new Date(baseStartDate) | ||||
|         while (cursor < occStart && occIdx < maxOccurrences) { | ||||
|           const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay) | ||||
|           const cWeeksDiff = Math.floor(cDaysDiff / 7) | ||||
|           if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++ | ||||
|           cursor.setDate(cursor.getDate() + 1) | ||||
|         } | ||||
|         if (occIdx >= maxOccurrences) continue | ||||
|         const occEnd = new Date(occStart) | ||||
|         occEnd.setDate(occStart.getDate() + spanDays) | ||||
|         const occStartStr = toLocalString(occStart) | ||||
|         const occEndStr = toLocalString(occEnd) | ||||
|         occurrences.push({ | ||||
|           ...baseEvent, | ||||
|           id: `${baseEvent.id}_repeat_${occIdx}_${occStart.getDay()}`, | ||||
|           startDate: occStartStr, | ||||
|           endDate: occEndStr, | ||||
|           isRepeatOccurrence: true, | ||||
|           repeatIndex: occIdx, | ||||
|         }) | ||||
|         continue | ||||
| // Consolidate already-provided day.events into contiguous spans (no recurrence generation) | ||||
| const eventSpans = computed(() => { | ||||
|   const weekEvents = new Map() | ||||
|   props.week.days.forEach((day, dayIndex) => { | ||||
|     day.events.forEach((ev) => { | ||||
|       const key = ev.id | ||||
|       if (!weekEvents.has(key)) { | ||||
|         weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex }) | ||||
|       } else { | ||||
|         // Handle other repeat types (months) | ||||
|         let intervalsPassed = 0 | ||||
|         const timeDiff = targetDate - baseStartDate | ||||
|         if (baseEvent.repeat === 'months') { | ||||
|           intervalsPassed = | ||||
|             (targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 + | ||||
|             (targetDate.getMonth() - baseStartDate.getMonth()) | ||||
|         } else { | ||||
|           continue | ||||
|         const ref = weekEvents.get(key) | ||||
|         ref.endIdx = Math.max(ref.endIdx, dayIndex) | ||||
|       } | ||||
|         const interval = baseEvent.repeatInterval || 1 | ||||
|         if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue | ||||
|  | ||||
|         // Check a few occurrences around the target date | ||||
|         const maxOccurrences = | ||||
|           baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) | ||||
|         if (maxOccurrences === 0) continue | ||||
|         const i = intervalsPassed | ||||
|         if (i >= maxOccurrences) continue | ||||
|         const currentStart = new Date(baseStartDate) | ||||
|         currentStart.setMonth(baseStartDate.getMonth() + i) | ||||
|         const currentEnd = new Date(currentStart) | ||||
|         currentEnd.setDate(currentStart.getDate() + spanDays) | ||||
|         // If target day lies within base (i===0) we skip because base is stored already | ||||
|         if (i === 0) { | ||||
|           // only skip if targetDate within base span | ||||
|           if (targetDate >= baseStartDate && targetDate <= baseEndDate) continue | ||||
|         } | ||||
|         const currentStartStr = toLocalString(currentStart) | ||||
|         const currentEndStr = toLocalString(currentEnd) | ||||
|         if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) { | ||||
|           occurrences.push({ | ||||
|             ...baseEvent, | ||||
|             id: `${baseEvent.id}_repeat_${i}`, | ||||
|             startDate: currentStartStr, | ||||
|             endDate: currentEndStr, | ||||
|             isRepeatOccurrence: true, | ||||
|             repeatIndex: i, | ||||
|     }) | ||||
|   }) | ||||
|   const arr = Array.from(weekEvents.values()) | ||||
|   arr.sort((a, b) => { | ||||
|     const spanA = a.endIdx - a.startIdx | ||||
|     const spanB = b.endIdx - b.startIdx | ||||
|     if (spanA !== spanB) return spanB - spanA | ||||
|     if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx | ||||
|     // For one-day events that are otherwise equal, sort by color (0 first) | ||||
|     if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) { | ||||
|       const colorA = a.colorId || 0 | ||||
|       const colorB = b.colorId || 0 | ||||
|       if (colorA !== colorB) return colorA - colorB | ||||
|     } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|     return String(a.id).localeCompare(String(b.id)) | ||||
|   }) | ||||
|   // Assign non-overlapping rows | ||||
|   const rowsLastEnd = [] | ||||
|   arr.forEach((ev) => { | ||||
|     let row = 0 | ||||
|     while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++ | ||||
|     if (row === rowsLastEnd.length) rowsLastEnd.push(-1) | ||||
|     rowsLastEnd[row] = ev.endIdx | ||||
|     ev.row = row + 1 | ||||
|   }) | ||||
|   return arr | ||||
| }) | ||||
|  | ||||
|   return occurrences | ||||
| } | ||||
|  | ||||
| // Extract original event ID from repeat occurrence ID | ||||
| function getOriginalEventId(eventId) { | ||||
|   if (typeof eventId === 'string' && eventId.includes('_repeat_')) { | ||||
|     return eventId.split('_repeat_')[0] | ||||
|   } | ||||
|   return eventId | ||||
| } | ||||
|  | ||||
| // Handle event click | ||||
| function handleEventClick(span) { | ||||
|   if (justDragged.value) return | ||||
|   // Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context | ||||
|   emit('event-click', span.id) | ||||
|   // Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n) | ||||
|   const idStr = span.id | ||||
|   const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_') | ||||
|   const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr | ||||
|   emit('event-click', { | ||||
|     id: baseId, | ||||
|     instanceId: span.id, | ||||
|     occurrenceIndex: span._recurrenceIndex != null ? span._recurrenceIndex : 0, | ||||
|   }) | ||||
| } | ||||
|  | ||||
| // Handle event pointer down for dragging | ||||
| function handleEventPointerDown(span, event) { | ||||
|   // Don't start drag if clicking on resize handle | ||||
|   if (event.target.classList.contains('resize-handle')) return | ||||
|  | ||||
|   event.stopPropagation() | ||||
|   // Do not preventDefault here to allow click unless drag threshold is passed | ||||
|  | ||||
|   // Get the date under the pointer | ||||
|   const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget) | ||||
|   const anchorDate = hit ? hit.date : span.startDate | ||||
|  | ||||
|   const idStr = span.id | ||||
|   const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_') | ||||
|   const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr | ||||
|   const isVirtual = hasVirtualMarker | ||||
|   // Determine which day within the span was grabbed so we maintain relative position | ||||
|   let anchorDate = span.startDate | ||||
|   try { | ||||
|     const spanDays = daysInclusive(span.startDate, span.endDate) | ||||
|     const targetEl = event.currentTarget | ||||
|     if (targetEl && spanDays > 0) { | ||||
|       const rect = targetEl.getBoundingClientRect() | ||||
|       const relX = event.clientX - rect.left | ||||
|       const dayWidth = rect.width / spanDays | ||||
|       let dayIndex = Math.floor(relX / dayWidth) | ||||
|       if (!isFinite(dayIndex)) dayIndex = 0 | ||||
|       if (dayIndex < 0) dayIndex = 0 | ||||
|       if (dayIndex >= spanDays) dayIndex = spanDays - 1 | ||||
|       anchorDate = addDaysStr(span.startDate, dayIndex) | ||||
|     } | ||||
|   } catch (e) { | ||||
|     // Fallback to startDate if any calculation fails | ||||
|   } | ||||
|   startLocalDrag( | ||||
|     { | ||||
|       id: span.id, | ||||
|       id: baseId, | ||||
|       originalId: span.id, | ||||
|       isVirtual, | ||||
|       mode: 'move', | ||||
|       pointerStartX: event.clientX, | ||||
|       pointerStartY: event.clientY, | ||||
| @@ -203,13 +135,17 @@ function handleEventPointerDown(span, event) { | ||||
|   ) | ||||
| } | ||||
|  | ||||
| // Handle resize handle pointer down | ||||
| function handleResizePointerDown(span, mode, event) { | ||||
|   event.stopPropagation() | ||||
|   // Start drag from the current edge; anchorDate not needed for resize | ||||
|   const idStr = span.id | ||||
|   const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_') | ||||
|   const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr | ||||
|   const isVirtual = hasVirtualMarker | ||||
|   startLocalDrag( | ||||
|     { | ||||
|       id: span.id, | ||||
|       id: baseId, | ||||
|       originalId: span.id, | ||||
|       isVirtual, | ||||
|       mode, | ||||
|       pointerStartX: event.clientX, | ||||
|       pointerStartY: event.clientY, | ||||
| @@ -221,94 +157,6 @@ function handleResizePointerDown(span, mode, event) { | ||||
|   ) | ||||
| } | ||||
|  | ||||
| // Get date under pointer coordinates | ||||
| function getDateUnderPointer(clientX, clientY, targetEl) { | ||||
|   // First try to find a day cell directly under the pointer | ||||
|   let element = document.elementFromPoint(clientX, clientY) | ||||
|  | ||||
|   // If we hit an event element, temporarily hide it and try again | ||||
|   const hiddenElements = [] | ||||
|   while (element && element.classList.contains('event-span')) { | ||||
|     element.style.pointerEvents = 'none' | ||||
|     hiddenElements.push(element) | ||||
|     element = document.elementFromPoint(clientX, clientY) | ||||
|   } | ||||
|  | ||||
|   // Restore pointer events for hidden elements | ||||
|   hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto')) | ||||
|  | ||||
|   if (element) { | ||||
|     // Look for a day cell with data-date attribute | ||||
|     const dayElement = element.closest('[data-date]') | ||||
|     if (dayElement && dayElement.dataset.date) { | ||||
|       return { date: dayElement.dataset.date } | ||||
|     } | ||||
|  | ||||
|     // Also check if we're over a week element and can calculate position | ||||
|     const weekElement = element.closest('.week-row') | ||||
|     if (weekElement) { | ||||
|       const rect = weekElement.getBoundingClientRect() | ||||
|       const relativeX = clientX - rect.left | ||||
|       const dayWidth = rect.width / 7 | ||||
|       const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth))) | ||||
|  | ||||
|       const daysGrid = weekElement.querySelector('.days-grid') | ||||
|       if (daysGrid && daysGrid.children[dayIndex]) { | ||||
|         const dayEl = daysGrid.children[dayIndex] | ||||
|         const date = dayEl?.dataset?.date | ||||
|         if (date) return { date } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Fallback: try to find the week overlay and calculate position | ||||
|   const overlayEl = targetEl?.closest('.week-overlay') | ||||
|   const weekElement = overlayEl ? overlayEl.parentElement : null | ||||
|   if (!weekElement) { | ||||
|     // If we're outside this week, try to find any week element under the pointer | ||||
|     const allWeekElements = document.querySelectorAll('.week-row') | ||||
|     let bestWeek = null | ||||
|     let bestDistance = Infinity | ||||
|  | ||||
|     for (const week of allWeekElements) { | ||||
|       const rect = week.getBoundingClientRect() | ||||
|       if (clientY >= rect.top && clientY <= rect.bottom) { | ||||
|         const distance = Math.abs(clientY - (rect.top + rect.height / 2)) | ||||
|         if (distance < bestDistance) { | ||||
|           bestDistance = distance | ||||
|           bestWeek = week | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (bestWeek) { | ||||
|       const rect = bestWeek.getBoundingClientRect() | ||||
|       const relativeX = clientX - rect.left | ||||
|       const dayWidth = rect.width / 7 | ||||
|       const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth))) | ||||
|  | ||||
|       const daysGrid = bestWeek.querySelector('.days-grid') | ||||
|       if (daysGrid && daysGrid.children[dayIndex]) { | ||||
|         const dayEl = daysGrid.children[dayIndex] | ||||
|         const date = dayEl?.dataset?.date | ||||
|         if (date) return { date } | ||||
|       } | ||||
|     } | ||||
|     return null | ||||
|   } | ||||
|  | ||||
|   const rect = weekElement.getBoundingClientRect() | ||||
|   const relativeX = clientX - rect.left | ||||
|   const dayWidth = rect.width / 7 | ||||
|   const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth))) | ||||
|  | ||||
|   if (props.week.days[dayIndex]) { | ||||
|     return { date: props.week.days[dayIndex].date } | ||||
|   } | ||||
|  | ||||
|   return null | ||||
| } | ||||
|  | ||||
| // Local drag handling | ||||
| function startLocalDrag(init, evt) { | ||||
|   const spanDays = daysInclusive(init.startDate, init.endDate) | ||||
| @@ -319,13 +167,39 @@ function startLocalDrag(init, evt) { | ||||
|     else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1 | ||||
|   } | ||||
|  | ||||
|   // Capture original repeating pattern & weekday (for weekly repeats) so we can rotate relative to original | ||||
|   let originalWeekday = null | ||||
|   let originalPattern = null | ||||
|   if (init.mode === 'move') { | ||||
|     try { | ||||
|       originalWeekday = new Date(init.startDate + 'T00:00:00').getDay() | ||||
|       const baseEv = store.getEventById(init.id) | ||||
|       if ( | ||||
|         baseEv && | ||||
|         baseEv.recur && | ||||
|         baseEv.recur.freq === 'weeks' && | ||||
|         Array.isArray(baseEv.recur.weekdays) | ||||
|       ) { | ||||
|         originalPattern = [...baseEv.recur.weekdays] | ||||
|       } | ||||
|     } catch {} | ||||
|   } | ||||
|  | ||||
|   dragState.value = { | ||||
|     ...init, | ||||
|     anchorOffset, | ||||
|     originSpanDays: spanDays, | ||||
|     eventMoved: false, | ||||
|     tentativeStart: init.startDate, | ||||
|     tentativeEnd: init.endDate, | ||||
|     originalWeekday, | ||||
|     originalPattern, | ||||
|     realizedId: null, // for virtual occurrence converted to real during drag | ||||
|   } | ||||
|  | ||||
|   // Begin compound history session (single snapshot after drag completes) | ||||
|   store.$history?.beginCompound() | ||||
|  | ||||
|   // Capture pointer events globally | ||||
|   if (evt.currentTarget && evt.pointerId !== undefined) { | ||||
|     try { | ||||
| @@ -335,14 +209,35 @@ function startLocalDrag(init, evt) { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Prevent default to avoid text selection and other interference | ||||
|   // Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll. | ||||
|   if (!(evt.pointerType === 'touch')) { | ||||
|     evt.preventDefault() | ||||
|   } | ||||
|  | ||||
|   window.addEventListener('pointermove', onDragPointerMove, { passive: false }) | ||||
|   window.addEventListener('pointerup', onDragPointerUp, { passive: false }) | ||||
|   window.addEventListener('pointercancel', onDragPointerUp, { passive: false }) | ||||
| } | ||||
|  | ||||
| // Determine date under pointer: traverse DOM to find day cell carrying data-date attribute | ||||
| function getDateUnderPointer(x, y, el) { | ||||
|   let cur = el | ||||
|   while (cur) { | ||||
|     if (cur.dataset && cur.dataset.date) { | ||||
|       return { date: cur.dataset.date } | ||||
|     } | ||||
|     cur = cur.parentElement | ||||
|   } | ||||
|   // Fallback: elementFromPoint scan | ||||
|   const probe = document.elementFromPoint(x, y) | ||||
|   let p = probe | ||||
|   while (p) { | ||||
|     if (p.dataset && p.dataset.date) return { date: p.dataset.date } | ||||
|     p = p.parentElement | ||||
|   } | ||||
|   return null | ||||
| } | ||||
|  | ||||
| function onDragPointerMove(e) { | ||||
|   const st = dragState.value | ||||
|   if (!st) return | ||||
| @@ -360,7 +255,66 @@ function onDragPointerMove(e) { | ||||
|  | ||||
|   const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) | ||||
|   if (!ns || !ne) return | ||||
|   applyRangeDuringDrag(st, ns, ne) | ||||
|   // Only proceed if changed | ||||
|   if (ns === st.tentativeStart && ne === st.tentativeEnd) return | ||||
|   st.tentativeStart = ns | ||||
|   st.tentativeEnd = ne | ||||
|   if (st.mode === 'move') { | ||||
|     if (st.isVirtual) { | ||||
|       // On first movement convert virtual occurrence into a real new event (split series) | ||||
|       if (!st.realizedId) { | ||||
|         const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne) | ||||
|         if (newId) { | ||||
|           st.realizedId = newId | ||||
|           st.id = newId | ||||
|           st.isVirtual = false | ||||
|         } else { | ||||
|           return | ||||
|         } | ||||
|       } else { | ||||
|         // Subsequent moves: update range without rotating pattern automatically | ||||
|         store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) | ||||
|       } | ||||
|     } else { | ||||
|       // Normal non-virtual move; rotate handled in setEventRange | ||||
|       store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) | ||||
|     } | ||||
|     // Manual rotation relative to original pattern (keeps pattern anchored to initially grabbed weekday) | ||||
|     if (st.originalPattern && st.originalWeekday != null) { | ||||
|       try { | ||||
|         const currentWeekday = new Date(ns + 'T00:00:00').getDay() | ||||
|         const shift = currentWeekday - st.originalWeekday | ||||
|         const rotated = store._rotateWeekdayPattern([...st.originalPattern], shift) | ||||
|         const ev = store.getEventById(st.id) | ||||
|         if (ev && ev.recur && ev.recur.freq === 'weeks') { | ||||
|           ev.recur.weekdays = rotated | ||||
|           store.touchEvents() | ||||
|         } | ||||
|       } catch {} | ||||
|     } | ||||
|   } else if (!st.isVirtual) { | ||||
|     // Resizes on real events update immediately | ||||
|     applyRangeDuringDrag( | ||||
|       { id: st.id, isVirtual: st.isVirtual, mode: st.mode, startDate: ns, endDate: ne }, | ||||
|       ns, | ||||
|       ne, | ||||
|     ) | ||||
|   } else if (st.isVirtual && (st.mode === 'resize-left' || st.mode === 'resize-right')) { | ||||
|     // For virtual occurrence resize: convert to real once, then adjust range | ||||
|     if (!st.realizedId) { | ||||
|       const initialStart = ns | ||||
|       const initialEnd = ne | ||||
|       const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, initialStart, initialEnd) | ||||
|       if (newId) { | ||||
|         st.realizedId = newId | ||||
|         st.id = newId | ||||
|         st.isVirtual = false | ||||
|       } else return | ||||
|     } | ||||
|     // Apply range change; rotate if left edge moved and weekday changed | ||||
|     const rotate = st.mode === 'resize-left' | ||||
|     store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate }) | ||||
|   } | ||||
| } | ||||
|  | ||||
| function onDragPointerUp(e) { | ||||
| @@ -377,6 +331,8 @@ function onDragPointerUp(e) { | ||||
|   } | ||||
|  | ||||
|   const moved = !!st.eventMoved | ||||
|   const finalStart = st.tentativeStart | ||||
|   const finalEnd = st.tentativeEnd | ||||
|   dragState.value = null | ||||
|  | ||||
|   window.removeEventListener('pointermove', onDragPointerMove) | ||||
| @@ -384,11 +340,27 @@ function onDragPointerUp(e) { | ||||
|   window.removeEventListener('pointercancel', onDragPointerUp) | ||||
|  | ||||
|   if (moved) { | ||||
|     // Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare) | ||||
|     if (st.isVirtual) { | ||||
|       applyRangeDuringDrag( | ||||
|         { | ||||
|           id: st.id, | ||||
|           isVirtual: st.isVirtual, | ||||
|           mode: st.mode, | ||||
|           startDate: finalStart, | ||||
|           endDate: finalEnd, | ||||
|         }, | ||||
|         finalStart, | ||||
|         finalEnd, | ||||
|       ) | ||||
|     } | ||||
|     justDragged.value = true | ||||
|     setTimeout(() => { | ||||
|       justDragged.value = false | ||||
|     }, 120) | ||||
|   } | ||||
|   // End compound session (snapshot if changed) | ||||
|   store.$history?.endCompound() | ||||
| } | ||||
|  | ||||
| function computeTentativeRangeFromPointer(st, dropDateStr) { | ||||
| @@ -416,133 +388,13 @@ function normalizeDateOrder(aStr, bStr) { | ||||
| } | ||||
|  | ||||
| function applyRangeDuringDrag(st, startDate, endDate) { | ||||
|   let ev = store.getEventById(st.id) | ||||
|   let isRepeatOccurrence = false | ||||
|   let baseId = st.id | ||||
|   let repeatIndex = 0 | ||||
|   let grabbedWeekday = null | ||||
|  | ||||
|   // If not found (repeat occurrences aren't stored) parse synthetic id | ||||
|   if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) { | ||||
|     const [bid, suffix] = st.id.split('_repeat_') | ||||
|     baseId = bid | ||||
|     ev = store.getEventById(baseId) | ||||
|     if (ev) { | ||||
|       const parts = suffix.split('_') | ||||
|       repeatIndex = parseInt(parts[0], 10) || 0 | ||||
|       grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null | ||||
|       isRepeatOccurrence = repeatIndex >= 0 | ||||
|   if (st.isVirtual) { | ||||
|     if (st.mode !== 'move') return // no resize for virtual occurrence | ||||
|     // Split-move: occurrence being dragged treated as first of new series | ||||
|     store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate) | ||||
|     return | ||||
|   } | ||||
|   } | ||||
|  | ||||
|   if (!ev) return | ||||
|  | ||||
|   const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move' | ||||
|   if (isRepeatOccurrence) { | ||||
|     if (repeatIndex === 0) { | ||||
|       store.setEventRange(baseId, startDate, endDate, { mode }) | ||||
|     } else { | ||||
|       if (!st.splitNewBaseId) { | ||||
|         const newId = store.splitRepeatSeries( | ||||
|           baseId, | ||||
|           repeatIndex, | ||||
|           startDate, | ||||
|           endDate, | ||||
|           grabbedWeekday, | ||||
|         ) | ||||
|         if (newId) { | ||||
|           st.splitNewBaseId = newId | ||||
|           st.id = newId | ||||
|           st.startDate = startDate | ||||
|           st.endDate = endDate | ||||
|         } | ||||
|       } else { | ||||
|         store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode }) | ||||
|       } | ||||
|     } | ||||
|   } else { | ||||
|     store.setEventRange(st.id, startDate, endDate, { mode }) | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Calculate event spans for this week | ||||
| const eventSpans = computed(() => { | ||||
|   const spans = [] | ||||
|   const weekEvents = new Map() | ||||
|  | ||||
|   // Collect events from all days in this week, including repeat occurrences | ||||
|   props.week.days.forEach((day, dayIndex) => { | ||||
|     // Get base events for this day | ||||
|     day.events.forEach((event) => { | ||||
|       if (!weekEvents.has(event.id)) { | ||||
|         weekEvents.set(event.id, { | ||||
|           ...event, | ||||
|           startIdx: dayIndex, | ||||
|           endIdx: dayIndex, | ||||
|         }) | ||||
|       } else { | ||||
|         const existing = weekEvents.get(event.id) | ||||
|         existing.endIdx = dayIndex | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     // Generate repeat occurrences for this day | ||||
|     const repeatOccurrences = generateRepeatOccurrencesForDate(day.date) | ||||
|     repeatOccurrences.forEach((event) => { | ||||
|       if (!weekEvents.has(event.id)) { | ||||
|         weekEvents.set(event.id, { | ||||
|           ...event, | ||||
|           startIdx: dayIndex, | ||||
|           endIdx: dayIndex, | ||||
|         }) | ||||
|       } else { | ||||
|         const existing = weekEvents.get(event.id) | ||||
|         existing.endIdx = dayIndex | ||||
|       } | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   // Convert to array and sort | ||||
|   const eventArray = Array.from(weekEvents.values()) | ||||
|   eventArray.sort((a, b) => { | ||||
|     // Sort by span length (longer first) | ||||
|     const spanA = a.endIdx - a.startIdx | ||||
|     const spanB = b.endIdx - b.startIdx | ||||
|     if (spanA !== spanB) return spanB - spanA | ||||
|  | ||||
|     // Then by start position | ||||
|     if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx | ||||
|  | ||||
|     // Then by start time if available | ||||
|     const timeA = a.startTime ? timeToMinutes(a.startTime) : 0 | ||||
|     const timeB = b.startTime ? timeToMinutes(b.startTime) : 0 | ||||
|     if (timeA !== timeB) return timeA - timeB | ||||
|  | ||||
|     // Fallback to ID | ||||
|     return String(a.id).localeCompare(String(b.id)) | ||||
|   }) | ||||
|  | ||||
|   // Assign rows to avoid overlaps | ||||
|   const rowsLastEnd = [] | ||||
|   eventArray.forEach((event) => { | ||||
|     let placedRow = 0 | ||||
|     while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) { | ||||
|       placedRow++ | ||||
|     } | ||||
|     if (placedRow === rowsLastEnd.length) { | ||||
|       rowsLastEnd.push(-1) | ||||
|     } | ||||
|     rowsLastEnd[placedRow] = event.endIdx | ||||
|     event.row = placedRow + 1 | ||||
|   }) | ||||
|  | ||||
|   return eventArray | ||||
| }) | ||||
|  | ||||
| function timeToMinutes(timeStr) { | ||||
|   if (!timeStr) return 0 | ||||
|   const [hours, minutes] = timeStr.split(':').map(Number) | ||||
|   return hours * 60 + minutes | ||||
|   store.setEventRange(st.id, startDate, endDate, { mode: st.mode }) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| @@ -564,7 +416,7 @@ function timeToMinutes(timeStr) { | ||||
|  | ||||
| .event-span { | ||||
|   padding: 0.1em 0.3em; | ||||
|   border-radius: 0.2em; | ||||
|   border-radius: 1em; | ||||
|   font-size: clamp(0.45em, 1.8vh, 0.75em); | ||||
|   font-weight: 600; | ||||
|   cursor: grab; | ||||
|   | ||||
							
								
								
									
										210
									
								
								src/components/HeaderControls.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								src/components/HeaderControls.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,210 @@ | ||||
| <template> | ||||
|   <Transition name="header-controls" appear> | ||||
|     <div v-if="isVisible" class="header-controls"> | ||||
|       <div class="today-date" @click="goToToday">{{ todayString }}</div> | ||||
|       <button | ||||
|         type="button" | ||||
|         class="hist-btn" | ||||
|         :disabled="!calendarStore.historyCanUndo" | ||||
|         @click="calendarStore.$history?.undo()" | ||||
|         title="Undo (Ctrl+Z)" | ||||
|         aria-label="Undo" | ||||
|       > | ||||
|         ↶ | ||||
|       </button> | ||||
|       <button | ||||
|         type="button" | ||||
|         class="hist-btn" | ||||
|         :disabled="!calendarStore.historyCanRedo" | ||||
|         @click="calendarStore.$history?.redo()" | ||||
|         title="Redo (Ctrl+Shift+Z)" | ||||
|         aria-label="Redo" | ||||
|       > | ||||
|         ↷ | ||||
|       </button> | ||||
|       <button | ||||
|         type="button" | ||||
|         class="settings-btn" | ||||
|         @click="openSettings" | ||||
|         aria-label="Open settings" | ||||
|         title="Settings" | ||||
|       > | ||||
|         ⚙ | ||||
|       </button> | ||||
|       <!-- Settings dialog now lives here --> | ||||
|       <SettingsDialog ref="settingsDialog" /> | ||||
|     </div> | ||||
|   </Transition> | ||||
|   <button | ||||
|     type="button" | ||||
|     class="toggle-btn" | ||||
|     @click="toggleVisibility" | ||||
|     :aria-label="isVisible ? 'Hide controls' : 'Show controls'" | ||||
|     :title="isVisible ? 'Hide controls' : 'Show controls'" | ||||
|   > | ||||
|     ⋯ | ||||
|   </button> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, ref, onMounted, onBeforeUnmount } from 'vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import { formatTodayString } from '@/utils/date' | ||||
| import SettingsDialog from '@/components/SettingsDialog.vue' | ||||
|  | ||||
| const calendarStore = useCalendarStore() | ||||
|  | ||||
| const todayString = computed(() => { | ||||
|   const d = new Date(calendarStore.now) | ||||
|   return formatTodayString(d) | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['go-to-today']) | ||||
|  | ||||
| function goToToday() { | ||||
|   // Emit the event so the parent can handle the viewport scrolling logic | ||||
|   // since this component doesn't have access to viewport refs | ||||
|   emit('go-to-today') | ||||
| } | ||||
|  | ||||
| // Screen size detection and visibility toggle | ||||
| const isVisible = ref(false) | ||||
|  | ||||
| function checkScreenSize() { | ||||
|   const isSmallScreen = window.innerHeight < 600 | ||||
|   // Default to open on large screens, closed on small screens | ||||
|   isVisible.value = !isSmallScreen | ||||
| } | ||||
|  | ||||
| function toggleVisibility() { | ||||
|   isVisible.value = !isVisible.value | ||||
| } | ||||
|  | ||||
| // Settings dialog integration | ||||
| const settingsDialog = ref(null) | ||||
| function openSettings() { | ||||
|   settingsDialog.value?.open() | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   checkScreenSize() | ||||
|   window.addEventListener('resize', checkScreenSize) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   window.removeEventListener('resize', checkScreenSize) | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .header-controls { | ||||
|   display: flex; | ||||
|   justify-content: end; | ||||
|   align-items: center; | ||||
|   margin-right: 1.5rem; | ||||
| } | ||||
| .toggle-btn { | ||||
|   position: fixed; | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   background: transparent; | ||||
|   border: none; | ||||
|   color: var(--muted); | ||||
|   padding: 0; | ||||
|   margin: 0.5em; | ||||
|   cursor: pointer; | ||||
|   font-size: 1em; | ||||
|   font-weight: 700; | ||||
|   line-height: 1; | ||||
|   display: inline-flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   outline: none; | ||||
|   width: 1em; | ||||
|   height: 1em; | ||||
|   transition: all 0.2s ease; | ||||
| } | ||||
| .toggle-btn:hover { | ||||
|   color: var(--strong); | ||||
| } | ||||
|  | ||||
| .toggle-btn:active { | ||||
|   transform: scale(0.9); | ||||
| } | ||||
| .header-controls-enter-active, | ||||
| .header-controls-leave-active { | ||||
|   transition: all 0.3s ease; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .header-controls-enter-from, | ||||
| .header-controls-leave-to { | ||||
|   opacity: 0; | ||||
|   max-height: 0; | ||||
|   transform: translateY(-20px); | ||||
| } | ||||
|  | ||||
| .header-controls-enter-to, | ||||
| .header-controls-leave-from { | ||||
|   opacity: 1; | ||||
|   max-height: 100px; | ||||
|   transform: translateY(0); | ||||
| } | ||||
|  | ||||
| .settings-btn { | ||||
|   background: transparent; | ||||
|   border: none; | ||||
|   color: var(--muted); | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
|   margin-right: 0.6rem; | ||||
|   cursor: pointer; | ||||
|   font-size: 1.5rem; | ||||
|   line-height: 1; | ||||
|   display: inline-flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   outline: none; | ||||
| } | ||||
|  | ||||
| .hist-btn { | ||||
|   background: transparent; | ||||
|   border: none; | ||||
|   color: var(--muted); | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
|   cursor: pointer; | ||||
|   font-size: 1.2rem; | ||||
|   line-height: 1; | ||||
|   display: inline-flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   outline: none; | ||||
|   width: 1.9rem; | ||||
|   height: 1.9rem; | ||||
| } | ||||
|  | ||||
| .hist-btn:disabled { | ||||
|   opacity: 0.35; | ||||
|   cursor: default; | ||||
| } | ||||
|  | ||||
| .hist-btn:not(:disabled):hover, | ||||
| .hist-btn:not(:disabled):focus-visible { | ||||
|   color: var(--strong); | ||||
| } | ||||
|  | ||||
| .hist-btn:active:not(:disabled) { | ||||
|   transform: scale(0.88); | ||||
| } | ||||
|  | ||||
| .settings-btn:hover { | ||||
|   color: var(--strong); | ||||
| } | ||||
|  | ||||
| .today-date { | ||||
|   white-space: pre-line; | ||||
|   text-align: center; | ||||
|   margin-right: 2rem; | ||||
| } | ||||
| </style> | ||||
| @@ -1,17 +1,21 @@ | ||||
| <template> | ||||
|   <div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll"> | ||||
|     <div class="jogwheel-content" ref="jogwheelContent" :style="{ height: jogwheelHeight + 'px' }"></div> | ||||
|     <div | ||||
|       class="jogwheel-content" | ||||
|       ref="jogwheelContent" | ||||
|       :style="{ height: jogwheelHeight + 'px' }" | ||||
|     ></div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { ref, computed, watch } from 'vue' | ||||
| import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   totalVirtualWeeks: { type: Number, required: true }, | ||||
|   rowHeight: { type: Number, required: true }, | ||||
|   viewportHeight: { type: Number, required: true }, | ||||
|   scrollTop: { type: Number, required: true } | ||||
|   scrollTop: { type: Number, required: true }, | ||||
| }) | ||||
|  | ||||
| const emit = defineEmits(['scroll-to']) | ||||
| @@ -19,6 +23,12 @@ const emit = defineEmits(['scroll-to']) | ||||
| const jogwheelViewport = ref(null) | ||||
| const jogwheelContent = ref(null) | ||||
| const syncLock = ref(null) | ||||
| // Drag state (no momentum, 1:1 mapping) | ||||
| const isDragging = ref(false) | ||||
| let mainStartScroll = 0 | ||||
| let dragScale = 1 // mainScrollPixels per mouse pixel | ||||
| let accumDelta = 0 | ||||
| let pointerLocked = false | ||||
|  | ||||
| // Jogwheel content height is 1/10th of main calendar | ||||
| const jogwheelHeight = computed(() => { | ||||
| @@ -30,13 +40,92 @@ const handleJogwheelScroll = () => { | ||||
|   syncFromJogwheel() | ||||
| } | ||||
|  | ||||
| function onDragMouseDown(e) { | ||||
|   if (e.button !== 0) return | ||||
|   isDragging.value = true | ||||
|   mainStartScroll = props.scrollTop | ||||
|   accumDelta = 0 | ||||
|   // Precompute scale between jogwheel scrollable range and main scrollable range | ||||
|   const mainScrollable = Math.max( | ||||
|     0, | ||||
|     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, | ||||
|   ) | ||||
|   let jogScrollable = 0 | ||||
|   if (jogwheelViewport.value && jogwheelContent.value) { | ||||
|     jogScrollable = Math.max( | ||||
|       0, | ||||
|       jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, | ||||
|     ) | ||||
|   } | ||||
|   dragScale = jogScrollable > 0 ? mainScrollable / jogScrollable : 1 | ||||
|   if (!isFinite(dragScale) || dragScale <= 0) dragScale = 1 | ||||
|   // Attempt pointer lock for relative movement | ||||
|   if (jogwheelViewport.value && jogwheelViewport.value.requestPointerLock) { | ||||
|     jogwheelViewport.value.requestPointerLock() | ||||
|   } | ||||
|   window.addEventListener('mousemove', onDragMouseMove, { passive: false }) | ||||
|   window.addEventListener('mouseup', onDragMouseUp, { passive: false }) | ||||
|   e.preventDefault() | ||||
| } | ||||
|  | ||||
| function onDragMouseMove(e) { | ||||
|   if (!isDragging.value) return | ||||
|   const dy = pointerLocked ? e.movementY : e.clientY // movementY only valid in pointer lock | ||||
|   accumDelta += dy | ||||
|   let desired = mainStartScroll - accumDelta * dragScale | ||||
|   if (desired < 0) desired = 0 | ||||
|   const maxScroll = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) | ||||
|   if (desired > maxScroll) desired = maxScroll | ||||
|   emit('scroll-to', desired) | ||||
|   e.preventDefault() | ||||
| } | ||||
|  | ||||
| function onDragMouseUp(e) { | ||||
|   if (!isDragging.value) return | ||||
|   isDragging.value = false | ||||
|   window.removeEventListener('mousemove', onDragMouseMove) | ||||
|   window.removeEventListener('mouseup', onDragMouseUp) | ||||
|   if (pointerLocked && document.exitPointerLock) document.exitPointerLock() | ||||
|   e.preventDefault() | ||||
| } | ||||
|  | ||||
| function handlePointerLockChange() { | ||||
|   pointerLocked = document.pointerLockElement === jogwheelViewport.value | ||||
|   if (!pointerLocked && isDragging.value) { | ||||
|     // Pointer lock lost (Esc) -> end drag gracefully | ||||
|     onDragMouseUp(new MouseEvent('mouseup')) | ||||
|   } | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   if (jogwheelViewport.value) { | ||||
|     jogwheelViewport.value.addEventListener('mousedown', onDragMouseDown) | ||||
|   } | ||||
|   document.addEventListener('pointerlockchange', handlePointerLockChange) | ||||
| }) | ||||
|  | ||||
| onBeforeUnmount(() => { | ||||
|   if (jogwheelViewport.value) { | ||||
|     jogwheelViewport.value.removeEventListener('mousedown', onDragMouseDown) | ||||
|   } | ||||
|   window.removeEventListener('mousemove', onDragMouseMove) | ||||
|   window.removeEventListener('mouseup', onDragMouseUp) | ||||
|   document.removeEventListener('pointerlockchange', handlePointerLockChange) | ||||
| }) | ||||
|  | ||||
| const syncFromJogwheel = () => { | ||||
|   if (!jogwheelViewport.value || !jogwheelContent.value) return | ||||
|  | ||||
|   syncLock.value = 'main' | ||||
|  | ||||
|   const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight) | ||||
|   const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) | ||||
|   const jogScrollable = Math.max( | ||||
|     0, | ||||
|     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, | ||||
|   ) | ||||
|   const mainScrollable = Math.max( | ||||
|     0, | ||||
|     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, | ||||
|   ) | ||||
|  | ||||
|   if (jogScrollable > 0) { | ||||
|     const ratio = jogwheelViewport.value.scrollTop / jogScrollable | ||||
| @@ -56,8 +145,14 @@ const syncFromMain = (mainScrollTop) => { | ||||
|  | ||||
|   syncLock.value = 'jogwheel' | ||||
|  | ||||
|   const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) | ||||
|   const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight) | ||||
|   const mainScrollable = Math.max( | ||||
|     0, | ||||
|     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, | ||||
|   ) | ||||
|   const jogScrollable = Math.max( | ||||
|     0, | ||||
|     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, | ||||
|   ) | ||||
|  | ||||
|   if (mainScrollable > 0) { | ||||
|     const ratio = mainScrollTop / mainScrollable | ||||
| @@ -70,12 +165,15 @@ const syncFromMain = (mainScrollTop) => { | ||||
| } | ||||
|  | ||||
| // Watch for main calendar scroll changes | ||||
| watch(() => props.scrollTop, (newScrollTop) => { | ||||
| watch( | ||||
|   () => props.scrollTop, | ||||
|   (newScrollTop) => { | ||||
|     syncFromMain(newScrollTop) | ||||
| }) | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| defineExpose({ | ||||
|   syncFromMain | ||||
|   syncFromMain, | ||||
| }) | ||||
| </script> | ||||
|  | ||||
| @@ -85,20 +183,12 @@ defineExpose({ | ||||
|   top: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   width: 3rem; /* Use fixed width since minmax() doesn't work for absolute positioning */ | ||||
|   width: var(--month-w); | ||||
|   overflow-y: auto; | ||||
|   overflow-x: hidden; | ||||
|   scrollbar-width: none; | ||||
|   z-index: 20; | ||||
|   cursor: ns-resize; | ||||
|   background: rgba(0, 0, 0, 0.05); /* Temporary background to see the area */ | ||||
|   /* Prevent text selection */ | ||||
|   -webkit-user-select: none; | ||||
|   -moz-user-select: none; | ||||
|   -ms-user-select: none; | ||||
|   user-select: none; | ||||
|   -webkit-touch-callout: none; | ||||
|   -webkit-tap-highlight-color: transparent; | ||||
| } | ||||
|  | ||||
| .jogwheel-viewport::-webkit-scrollbar { | ||||
|   | ||||
| @@ -7,21 +7,20 @@ | ||||
|     role="spinbutton" | ||||
|     :aria-valuemin="minValue" | ||||
|     :aria-valuemax="maxValue" | ||||
|     :aria-valuenow="isPrefix(current) ? undefined : current" | ||||
|     :aria-valuenow="isPrefix(model) ? undefined : model" | ||||
|     :aria-valuetext="display" | ||||
|     tabindex="0" | ||||
|     @pointerdown="onPointerDown" | ||||
|     @keydown="onKey" | ||||
|     @wheel.prevent="onWheel" | ||||
|   > | ||||
|     <span class="value" :title="String(current)">{{ display }}</span> | ||||
|     <span class="value" :title="String(model)">{{ display }}</span> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue' | ||||
|  | ||||
| const model = defineModel({ type: Number, default: 0 }) | ||||
|  | ||||
| const model = defineModel({ default: 0 }) | ||||
| const props = defineProps({ | ||||
|   min: { type: Number, default: 0 }, | ||||
|   max: { type: Number, default: 999 }, | ||||
| @@ -36,111 +35,122 @@ const props = defineProps({ | ||||
|   numberPostfix: { type: String, default: '' }, | ||||
|   clamp: { type: Boolean, default: true }, | ||||
|   pixelsPerStep: { type: Number, default: 16 }, | ||||
|   // Movement now restricted to horizontal (x). Prop retained for compatibility but ignored. | ||||
|   axis: { type: String, default: 'x' }, | ||||
|   ariaLabel: { type: String, default: '' }, | ||||
|   extraClass: { type: String, default: '' }, | ||||
| }) | ||||
|  | ||||
| const minValue = computed(() => props.min) | ||||
| const maxValue = computed(() => props.max) | ||||
|  | ||||
| // Helper to check if a value is in the prefix values | ||||
| const isPrefix = (value) => { | ||||
|   return props.prefixValues.some((prefix) => prefix.value === value) | ||||
| } | ||||
|  | ||||
| // Helper to get the display for a prefix value | ||||
| const getPrefixDisplay = (value) => { | ||||
|   const prefix = props.prefixValues.find((p) => p.value === value) | ||||
|   return prefix ? prefix.display : null | ||||
| } | ||||
|  | ||||
| // Get all valid values in order: prefixValues, then min to max | ||||
| const isPrefix = (value) => props.prefixValues.some((p) => p.value === value) | ||||
| const getPrefixDisplay = (value) => | ||||
|   props.prefixValues.find((p) => p.value === value)?.display ?? null | ||||
| const allValidValues = computed(() => { | ||||
|   const prefixVals = props.prefixValues.map((p) => p.value) | ||||
|   const numericVals = [] | ||||
|   for (let i = props.min; i <= props.max; i += props.step) { | ||||
|     numericVals.push(i) | ||||
|   } | ||||
|   for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i) | ||||
|   return [...prefixVals, ...numericVals] | ||||
| }) | ||||
|  | ||||
| const current = computed({ | ||||
|   get() { | ||||
|     return model.value | ||||
|   }, | ||||
|   set(v) { | ||||
|     if (props.clamp) { | ||||
|       // If it's a prefix value, allow it | ||||
|       if (isPrefix(v)) { | ||||
|         model.value = v | ||||
|         return | ||||
|       } | ||||
|       // Otherwise clamp to numeric range | ||||
|       if (v < props.min) v = props.min | ||||
|       if (v > props.max) v = props.max | ||||
|     } | ||||
|     model.value = v | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| const display = computed(() => { | ||||
|   const prefixDisplay = getPrefixDisplay(current.value) | ||||
|   if (prefixDisplay !== null) { | ||||
|     // For prefix values, show only the display text without number prefix/postfix | ||||
|     return prefixDisplay | ||||
|   } | ||||
|   // For numeric values, include prefix and postfix | ||||
|   const numericValue = String(current.value) | ||||
|   return `${props.numberPrefix}${numericValue}${props.numberPostfix}` | ||||
|   const prefixDisplay = getPrefixDisplay(model.value) | ||||
|   if (prefixDisplay !== null) return prefixDisplay | ||||
|   return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}` | ||||
| }) | ||||
|  | ||||
| // Drag handling | ||||
| const dragging = ref(false) | ||||
| const rootEl = ref(null) | ||||
| let startX = 0 | ||||
| let startY = 0 | ||||
| let startVal = 0 | ||||
|  | ||||
| let accumX = 0 | ||||
| let lastClientX = 0 | ||||
| const pointerLocked = ref(false) | ||||
| function updatePointerLocked() { | ||||
|   pointerLocked.value = | ||||
|     typeof document !== 'undefined' && document.pointerLockElement === rootEl.value | ||||
|   if (pointerLocked.value) { | ||||
|     accumX = 0 | ||||
|     startX = 0 | ||||
|   } | ||||
| } | ||||
| function addPointerLockListeners() { | ||||
|   if (typeof document === 'undefined') return | ||||
|   document.addEventListener('pointerlockchange', updatePointerLocked) | ||||
|   document.addEventListener('pointerlockerror', updatePointerLocked) | ||||
| } | ||||
| function removePointerLockListeners() { | ||||
|   if (typeof document === 'undefined') return | ||||
|   document.removeEventListener('pointerlockchange', updatePointerLocked) | ||||
|   document.removeEventListener('pointerlockerror', updatePointerLocked) | ||||
| } | ||||
| function onPointerDown(e) { | ||||
|   e.preventDefault() | ||||
|   startX = e.clientX | ||||
|   startY = e.clientY | ||||
|   startVal = current.value | ||||
|   lastClientX = e.clientX | ||||
|   accumX = 0 | ||||
|   dragging.value = true | ||||
|   try { | ||||
|     e.currentTarget.setPointerCapture(e.pointerId) | ||||
|     e.currentTarget.setPointerCapture?.(e.pointerId) | ||||
|   } catch {} | ||||
|   rootEl.value?.addEventListener('pointermove', onPointerMove) | ||||
|   rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true }) | ||||
|   rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true }) | ||||
|   if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) { | ||||
|     addPointerLockListeners() | ||||
|     try { | ||||
|       rootEl.value.requestPointerLock() | ||||
|     } catch {} | ||||
|   } | ||||
|   document.addEventListener('pointermove', onPointerMove) | ||||
|   document.addEventListener('pointerup', onPointerUp, { once: true }) | ||||
|   document.addEventListener('pointercancel', onPointerCancel, { once: true }) | ||||
| } | ||||
| function onPointerMove(e) { | ||||
|   if (!dragging.value) return | ||||
|   // Prevent page scroll on touch while dragging | ||||
|   if (e.pointerType === 'touch') e.preventDefault() | ||||
|   const primary = e.clientX - startX // horizontal only | ||||
|   const steps = Math.trunc(primary / props.pixelsPerStep) | ||||
|  | ||||
|   // Find current value index in all valid values | ||||
|   const currentIndex = allValidValues.value.indexOf(startVal) | ||||
|   if (currentIndex === -1) return // shouldn't happen | ||||
|  | ||||
|   const newIndex = currentIndex + steps | ||||
|   if (props.clamp) { | ||||
|     const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1)) | ||||
|     const next = allValidValues.value[clampedIndex] | ||||
|     if (next !== current.value) current.value = next | ||||
|   let dx = pointerLocked.value ? e.movementX || 0 : e.clientX - lastClientX | ||||
|   if (!pointerLocked.value) lastClientX = e.clientX | ||||
|   if (!dx) return | ||||
|   accumX += dx | ||||
|   const stepSize = props.pixelsPerStep || 1 | ||||
|   let steps = Math.trunc(accumX / stepSize) | ||||
|   if (steps === 0) return | ||||
|   const applySteps = (count) => { | ||||
|     if (!count) return | ||||
|     let direction = count > 0 ? 1 : -1 | ||||
|     let remaining = Math.abs(count) | ||||
|     let curVal = model.value | ||||
|     const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal) | ||||
|     let idx = allValidValues.value.indexOf(curVal) | ||||
|     if (idx === -1) { | ||||
|       if (!isNumeric) { | ||||
|         curVal = props.prefixValues.length ? props.prefixValues[0].value : props.min | ||||
|       } else { | ||||
|     if (newIndex >= 0 && newIndex < allValidValues.value.length) { | ||||
|       const next = allValidValues.value[newIndex] | ||||
|       if (next !== current.value) current.value = next | ||||
|         if (direction > 0) curVal = props.min | ||||
|         else | ||||
|           curVal = props.prefixValues.length | ||||
|             ? props.prefixValues[props.prefixValues.length - 1].value | ||||
|             : props.min | ||||
|       } | ||||
|       remaining-- | ||||
|     } | ||||
|     while (remaining > 0) { | ||||
|       idx = allValidValues.value.indexOf(curVal) | ||||
|       if (idx === -1) break | ||||
|       let targetIdx = idx + direction | ||||
|       if (props.clamp) targetIdx = Math.max(0, Math.min(targetIdx, allValidValues.value.length - 1)) | ||||
|       if (targetIdx < 0 || targetIdx >= allValidValues.value.length || targetIdx === idx) break | ||||
|       curVal = allValidValues.value[targetIdx] | ||||
|       remaining-- | ||||
|     } | ||||
|     model.value = curVal | ||||
|   } | ||||
|   applySteps(steps) | ||||
|   accumX -= steps * stepSize | ||||
| } | ||||
| function endDragListeners() { | ||||
|   rootEl.value?.removeEventListener('pointermove', onPointerMove) | ||||
|   document.removeEventListener('pointermove', onPointerMove) | ||||
|   if (pointerLocked.value && document.exitPointerLock) { | ||||
|     try { | ||||
|       document.exitPointerLock() | ||||
|     } catch {} | ||||
|   } | ||||
|   removePointerLockListeners() | ||||
| } | ||||
| function onPointerUp() { | ||||
|   dragging.value = false | ||||
| @@ -150,52 +160,43 @@ function onPointerCancel() { | ||||
|   dragging.value = false | ||||
|   endDragListeners() | ||||
| } | ||||
|  | ||||
| function onKey(e) { | ||||
|   const key = e.key | ||||
|   let handled = false | ||||
|   let newValue = null | ||||
|  | ||||
|   // Find current value index in all valid values | ||||
|   const currentIndex = allValidValues.value.indexOf(current.value) | ||||
|  | ||||
|   const currentIndex = allValidValues.value.indexOf(model.value) | ||||
|   switch (key) { | ||||
|     case 'ArrowRight': | ||||
|     case 'ArrowUp': | ||||
|       if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) { | ||||
|       if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) | ||||
|         newValue = allValidValues.value[currentIndex + 1] | ||||
|       } else if (currentIndex === -1) { | ||||
|         // Current value not in list, try to increment normally | ||||
|         newValue = current.value + props.step | ||||
|       else if (currentIndex === -1) { | ||||
|         const curVal = model.value | ||||
|         const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal) | ||||
|         if (!isNumeric && props.prefixValues.length) newValue = props.prefixValues[0].value | ||||
|         else newValue = props.min | ||||
|       } | ||||
|       handled = true | ||||
|       break | ||||
|     case 'ArrowLeft': | ||||
|     case 'ArrowDown': | ||||
|       if (currentIndex !== -1 && currentIndex > 0) { | ||||
|         newValue = allValidValues.value[currentIndex - 1] | ||||
|       } else if (currentIndex === -1) { | ||||
|         // Current value not in list, try to decrement normally | ||||
|         newValue = current.value - props.step | ||||
|       } | ||||
|       if (currentIndex !== -1 && currentIndex > 0) newValue = allValidValues.value[currentIndex - 1] | ||||
|       else if (currentIndex === -1) | ||||
|         newValue = props.prefixValues.length | ||||
|           ? props.prefixValues[props.prefixValues.length - 1].value | ||||
|           : props.min | ||||
|       handled = true | ||||
|       break | ||||
|     case 'PageUp': | ||||
|       if (currentIndex !== -1) { | ||||
|         const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1) | ||||
|         newValue = allValidValues.value[newIndex] | ||||
|       } else { | ||||
|         newValue = current.value + props.step * 10 | ||||
|       } | ||||
|       if (currentIndex !== -1) | ||||
|         newValue = | ||||
|           allValidValues.value[Math.min(currentIndex + 10, allValidValues.value.length - 1)] | ||||
|       else newValue = model.value + props.step * 10 | ||||
|       handled = true | ||||
|       break | ||||
|     case 'PageDown': | ||||
|       if (currentIndex !== -1) { | ||||
|         const newIndex = Math.max(currentIndex - 10, 0) | ||||
|         newValue = allValidValues.value[newIndex] | ||||
|       } else { | ||||
|         newValue = current.value - props.step * 10 | ||||
|       } | ||||
|       if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)] | ||||
|       else newValue = model.value - props.step * 10 | ||||
|       handled = true | ||||
|       break | ||||
|     case 'Home': | ||||
| @@ -207,16 +208,32 @@ function onKey(e) { | ||||
|       handled = true | ||||
|       break | ||||
|   } | ||||
|  | ||||
|   if (newValue !== null) { | ||||
|     current.value = newValue | ||||
|   } | ||||
|  | ||||
|   if (newValue !== null) model.value = newValue | ||||
|   if (handled) { | ||||
|     e.preventDefault() | ||||
|     e.stopPropagation() | ||||
|   } | ||||
| } | ||||
| function onWheel(e) { | ||||
|   const direction = e.deltaY < 0 ? -1 : e.deltaY > 0 ? 1 : 0 | ||||
|   if (direction === 0) return | ||||
|   const idx = allValidValues.value.indexOf(model.value) | ||||
|   if (idx !== -1) { | ||||
|     const nextIdx = idx + direction | ||||
|     if (nextIdx >= 0 && nextIdx < allValidValues.value.length) | ||||
|       model.value = allValidValues.value[nextIdx] | ||||
|   } else { | ||||
|     const curVal = model.value | ||||
|     const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal) | ||||
|     if (!isNumeric) | ||||
|       model.value = props.prefixValues.length ? props.prefixValues[0].value : props.min | ||||
|     else if (direction > 0) model.value = props.min | ||||
|     else | ||||
|       model.value = props.prefixValues.length | ||||
|         ? props.prefixValues[props.prefixValues.length - 1].value | ||||
|         : props.min | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| @@ -226,18 +243,14 @@ function onKey(e) { | ||||
|   display: inline-flex; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   padding: 0 0.4rem; | ||||
|   gap: 0.25rem; | ||||
|   border: 1px solid var(--input-border, var(--muted)); | ||||
|   background: var(--panel-alt); | ||||
|   border-radius: 0.4rem; | ||||
|   min-height: 1.8rem; | ||||
|   background: none; | ||||
|   font-variant-numeric: tabular-nums; | ||||
|   touch-action: none; /* allow custom drag without scrolling */ | ||||
|   touch-action: none; | ||||
| } | ||||
| .mini-stepper.drag-mode:focus-visible { | ||||
|   outline: 2px solid var(--input-focus, #2563eb); | ||||
|   outline-offset: 2px; | ||||
|   box-shadow: 0 0 0 2px var(--input-focus, #2563eb); | ||||
|   outline: none; | ||||
| } | ||||
| .mini-stepper.drag-mode .value { | ||||
|   font-weight: 600; | ||||
|   | ||||
							
								
								
									
										309
									
								
								src/components/SettingsDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										309
									
								
								src/components/SettingsDialog.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,309 @@ | ||||
| <script setup> | ||||
| import { ref, computed } from 'vue' | ||||
| import BaseDialog from './BaseDialog.vue' | ||||
| import { useCalendarStore } from '@/stores/CalendarStore' | ||||
| import WeekdaySelector from './WeekdaySelector.vue' | ||||
|  | ||||
| const show = ref(false) | ||||
| const calendarStore = useCalendarStore() | ||||
|  | ||||
| // Reactive bindings to store | ||||
| const firstDay = computed({ | ||||
|   get: () => calendarStore.config.first_day, | ||||
|   set: (v) => (calendarStore.config.first_day = v), | ||||
| }) | ||||
| const weekend = computed({ | ||||
|   get: () => calendarStore.weekend, | ||||
|   set: (v) => (calendarStore.weekend = [...v]), | ||||
| }) | ||||
|  | ||||
| // Holiday settings - simplified | ||||
| const holidayMode = computed({ | ||||
|   get: () => { | ||||
|     if (!calendarStore.config.holidays.enabled) { | ||||
|       return 'none' | ||||
|     } | ||||
|     return calendarStore.config.holidays.country || 'auto' | ||||
|   }, | ||||
|   set: (v) => { | ||||
|     if (v === 'none') { | ||||
|       calendarStore.config.holidays.enabled = false | ||||
|       calendarStore.config.holidays.country = null | ||||
|       calendarStore.config.holidays.state = null | ||||
|     } else if (v === 'auto') { | ||||
|       const detectedCountry = getDetectedCountryCode() | ||||
|       if (detectedCountry) { | ||||
|         calendarStore.config.holidays.enabled = true | ||||
|         calendarStore.config.holidays.country = 'auto' | ||||
|         calendarStore.config.holidays.state = null | ||||
|         calendarStore.initializeHolidays('auto', null, null) | ||||
|       } else { | ||||
|         calendarStore.config.holidays.enabled = false | ||||
|         calendarStore.config.holidays.country = null | ||||
|         calendarStore.config.holidays.state = null | ||||
|       } | ||||
|     } else { | ||||
|       calendarStore.config.holidays.enabled = true | ||||
|       calendarStore.config.holidays.country = v | ||||
|       calendarStore.config.holidays.state = null | ||||
|       calendarStore.initializeHolidays(v, null, null) | ||||
|     } | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| const holidayState = computed({ | ||||
|   get: () => calendarStore.config.holidays.state, | ||||
|   set: (v) => { | ||||
|     calendarStore.config.holidays.state = v | ||||
|     const country = | ||||
|       calendarStore.config.holidays.country === 'auto' | ||||
|         ? 'auto' | ||||
|         : calendarStore.config.holidays.country | ||||
|     calendarStore.initializeHolidays(country, v, calendarStore.config.holidays.region) | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| // Get detected country code | ||||
| function getDetectedCountryCode() { | ||||
|   const locale = navigator.language || navigator.languages?.[0] | ||||
|   if (!locale) return null | ||||
|  | ||||
|   const parts = locale.split('-') | ||||
|   if (parts.length < 2) return null | ||||
|  | ||||
|   return parts[parts.length - 1].toUpperCase() | ||||
| } // Get display name for any country code | ||||
| function getCountryDisplayName(countryCode) { | ||||
|   if (!countryCode || countryCode.length !== 2) { | ||||
|     return countryCode | ||||
|   } | ||||
|   try { | ||||
|     const regionNames = new Intl.DisplayNames([navigator.language || 'en'], { type: 'region' }) | ||||
|     return regionNames.of(countryCode) || countryCode | ||||
|   } catch { | ||||
|     return countryCode | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Get display name for auto option | ||||
| const autoDisplayName = computed(() => { | ||||
|   const detectedCode = getDetectedCountryCode() | ||||
|   if (!detectedCode) return 'Auto' | ||||
|   return getCountryDisplayName(detectedCode) | ||||
| }) | ||||
|  | ||||
| // Get state/province name from state code | ||||
| function getStateName(stateCode, countryCode) { | ||||
|   return stateCode | ||||
| } | ||||
|  | ||||
| // Get available countries and states | ||||
| const availableCountries = computed(() => { | ||||
|   try { | ||||
|     const countries = calendarStore.getAvailableCountries() | ||||
|     const countryArray = Array.isArray(countries) ? countries : ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] | ||||
|  | ||||
|     return countryArray.sort((a, b) => { | ||||
|       const nameA = getCountryDisplayName(a) | ||||
|       const nameB = getCountryDisplayName(b) | ||||
|       return nameA.localeCompare(nameB, navigator.language || 'en') | ||||
|     }) | ||||
|   } catch (error) { | ||||
|     console.warn('Failed to get available countries:', error) | ||||
|     return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] | ||||
|   } | ||||
| }) | ||||
| const availableStates = computed(() => { | ||||
|   try { | ||||
|     if (holidayMode.value === 'none') return [] | ||||
|     let country = holidayMode.value | ||||
|     if (holidayMode.value === 'auto') { | ||||
|       country = getDetectedCountryCode() | ||||
|       if (!country) return [] | ||||
|     } | ||||
|     const states = calendarStore.getAvailableStates(country) | ||||
|     return Array.isArray(states) ? states : [] | ||||
|   } catch (error) { | ||||
|     console.warn('Failed to get available states:', error) | ||||
|     return [] | ||||
|   } | ||||
| }) | ||||
|  | ||||
| function open() { | ||||
|   // Toggle behavior: if already open, close instead | ||||
|   show.value = !show.value | ||||
| } | ||||
| function close() { | ||||
|   show.value = false | ||||
| } | ||||
| function resetAll() { | ||||
|   if (confirm('Delete ALL events and reset settings? This cannot be undone.')) { | ||||
|     if (typeof calendarStore.$reset === 'function') { | ||||
|       calendarStore.$reset() | ||||
|     } else { | ||||
|       const now = new Date() | ||||
|       calendarStore.today = now.toISOString().slice(0, 10) | ||||
|       calendarStore.now = now.toISOString() | ||||
|       calendarStore.events = new Map() | ||||
|       calendarStore.weekend = [6, 0] | ||||
|       calendarStore.config.first_day = 1 | ||||
|     } | ||||
|     close() | ||||
|   } | ||||
| } | ||||
| defineExpose({ open }) | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <BaseDialog | ||||
|     v-model="show" | ||||
|     title="Settings" | ||||
|     class="settings-modal" | ||||
|     :style="{ top: '4.5rem', right: '2rem', bottom: 'auto', left: 'auto', transform: 'none' }" | ||||
|   > | ||||
|     <div class="setting-group"> | ||||
|       <label class="ec-field"> | ||||
|         <span>First day of week</span> | ||||
|         <select v-model.number="firstDay"> | ||||
|           <option :value="0">Sunday</option> | ||||
|           <option :value="1">Monday</option> | ||||
|           <option :value="2">Tuesday</option> | ||||
|           <option :value="3">Wednesday</option> | ||||
|           <option :value="4">Thursday</option> | ||||
|           <option :value="5">Friday</option> | ||||
|           <option :value="6">Saturday</option> | ||||
|         </select> | ||||
|       </label> | ||||
|       <div class="weekend-select ec-field"> | ||||
|         <span>Weekend days</span> | ||||
|         <WeekdaySelector v-model="weekend" :first-day="firstDay" /> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="setting-group"> | ||||
|       <label class="ec-field"> | ||||
|         <span>Holiday Region</span> | ||||
|         <div class="holiday-row"> | ||||
|           <select v-model="holidayMode" class="country-select"> | ||||
|             <option value="none">Do not show holidays</option> | ||||
|             <option v-if="getDetectedCountryCode()" value="auto"> | ||||
|               {{ autoDisplayName }} (Auto) | ||||
|             </option> | ||||
|             <option v-for="country in availableCountries" :key="country" :value="country"> | ||||
|               {{ getCountryDisplayName(country) }} | ||||
|             </option> | ||||
|           </select> | ||||
|  | ||||
|           <select | ||||
|             v-if="holidayMode !== 'none' && availableStates.length > 0" | ||||
|             v-model="holidayState" | ||||
|             class="state-select" | ||||
|           > | ||||
|             <option value="">None</option> | ||||
|             <option v-for="state in availableStates" :key="state" :value="state"> | ||||
|               {{ state }} | ||||
|             </option> | ||||
|           </select> | ||||
|         </div> | ||||
|       </label> | ||||
|     </div> | ||||
|     <template #footer> | ||||
|       <div class="footer-row split"> | ||||
|         <div class="left"> | ||||
|           <button type="button" class="ec-btn delete-btn" @click="resetAll">Clear All Data</button> | ||||
|         </div> | ||||
|         <div class="right"> | ||||
|           <button type="button" class="ec-btn close-btn" @click="close">Close</button> | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|   </BaseDialog> | ||||
| </template> | ||||
|  | ||||
| <style scoped> | ||||
| .setting-group { | ||||
|   display: grid; | ||||
|   gap: 1rem; | ||||
| } | ||||
| .setting-group h3 { | ||||
|   margin: 0; | ||||
|   padding: 0; | ||||
|   font-size: 1rem; | ||||
|   color: var(--strong); | ||||
| } | ||||
| .ec-field { | ||||
|   display: grid; | ||||
|   gap: 0.25rem; | ||||
| } | ||||
| .ec-field > span { | ||||
|   font-size: 0.75rem; | ||||
|   color: var(--muted); | ||||
| } | ||||
| .holiday-settings { | ||||
|   display: grid; | ||||
|   gap: 0.75rem; | ||||
|   margin-left: 1rem; | ||||
|   padding-left: 1rem; | ||||
|   border-left: 2px solid var(--border-color); | ||||
| } | ||||
| select { | ||||
|   border: 1px solid var(--muted); | ||||
|   background: var(--panel-alt, transparent); | ||||
|   color: var(--ink); | ||||
|   padding: 0.4rem 0.5rem; | ||||
|   border-radius: 0.4rem; | ||||
| } | ||||
|  | ||||
| .holiday-row { | ||||
|   display: flex; | ||||
|   gap: 0.5rem; | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .country-select { | ||||
|   flex: 1; | ||||
|   min-width: 0; | ||||
| } | ||||
|  | ||||
| .state-select { | ||||
|   flex: 0 0 auto; | ||||
|   min-width: 120px; | ||||
| } | ||||
| /* WeekdaySelector display tweaks */ | ||||
| .footer-row { | ||||
|   display: flex; | ||||
|   justify-content: flex-end; | ||||
|   gap: 0.5rem; | ||||
|   width: 100%; | ||||
| } | ||||
| .footer-row.split { | ||||
|   justify-content: space-between; | ||||
| } | ||||
| .footer-row.split .left, | ||||
| .footer-row.split .right { | ||||
|   display: flex; | ||||
|   gap: 0.5rem; | ||||
| } | ||||
| .ec-btn { | ||||
|   border: 1px solid var(--muted); | ||||
|   background: transparent; | ||||
|   color: var(--ink); | ||||
|   padding: 0.5rem 0.8rem; | ||||
|   border-radius: 0.4rem; | ||||
|   cursor: pointer; | ||||
| } | ||||
| .ec-btn.close-btn { | ||||
|   background: var(--panel-alt); | ||||
|   border-color: var(--muted); | ||||
|   font-weight: 500; | ||||
| } | ||||
| .ec-btn.delete-btn { | ||||
|   background: hsl(0, 70%, 50%); | ||||
|   color: #fff; | ||||
|   border-color: transparent; | ||||
|   font-weight: 500; | ||||
| } | ||||
| .ec-btn.delete-btn:hover { | ||||
|   background: hsl(0, 70%, 45%); | ||||
| } | ||||
| </style> | ||||
| @@ -3,11 +3,13 @@ | ||||
|     <div class="week-label">W{{ weekNumber }}</div> | ||||
|     <div class="days-grid"> | ||||
|       <DayCell v-for="day in days" :key="day.dateStr" :day="day" /> | ||||
|       <div class="week-overlay"> | ||||
|         <!-- Event spans will be rendered here --> | ||||
|       <div class="week-overlay"></div> | ||||
|     </div> | ||||
|     </div> | ||||
|     <div v-if="monthLabel" class="month-name-label" :style="{ height: `${monthLabel.weeksSpan * 64}px` }"> | ||||
|     <div | ||||
|       v-if="monthLabel" | ||||
|       class="month-name-label" | ||||
|       :style="{ height: `${monthLabel.weeksSpan * 64}px` }" | ||||
|     > | ||||
|       <span>{{ monthLabel.name }} '{{ monthLabel.year }}</span> | ||||
|     </div> | ||||
|   </div> | ||||
| @@ -16,39 +18,44 @@ | ||||
| <script setup> | ||||
| import { computed } from 'vue' | ||||
| import DayCell from './DayCell.vue' | ||||
| import { isoWeekInfo, toLocalString, getLocalizedMonthName, monthAbbr } from '@/utils/date' | ||||
| import { | ||||
|   toLocalString, | ||||
|   getLocalizedMonthName, | ||||
|   monthAbbr, | ||||
|   DEFAULT_TZ, | ||||
|   getISOWeek, | ||||
| } from '@/utils/date' | ||||
| import { addDays } from 'date-fns' | ||||
|  | ||||
| const props = defineProps({ | ||||
|   week: { | ||||
|     type: Object, | ||||
|     required: true | ||||
|   } | ||||
|     required: true, | ||||
|   }, | ||||
| }) | ||||
|  | ||||
| const weekNumber = computed(() => { | ||||
|   return isoWeekInfo(props.week.monday).week | ||||
| }) | ||||
| const weekNumber = computed(() => getISOWeek(props.week.monday)) | ||||
|  | ||||
| const days = computed(() => { | ||||
|   const d = new Date(props.week.monday) | ||||
|   const result = [] | ||||
|   for (let i = 0; i < 7; i++) { | ||||
|     const dateStr = toLocalString(d) | ||||
|     const dateStr = toLocalString(d, DEFAULT_TZ) | ||||
|     result.push({ | ||||
|       date: new Date(d), | ||||
|       dateStr, | ||||
|       dayOfMonth: d.getDate(), | ||||
|       month: d.getMonth(), | ||||
|       isFirstDayOfMonth: d.getDate() === 1, | ||||
|       monthClass: monthAbbr[d.getMonth()] | ||||
|       monthClass: monthAbbr[d.getMonth()], | ||||
|     }) | ||||
|     d.setDate(d.getDate() + 1) | ||||
|     d.setTime(addDays(d, 1).getTime()) | ||||
|   } | ||||
|   return result | ||||
| }) | ||||
|  | ||||
| const monthLabel = computed(() => { | ||||
|   const firstDayOfMonth = days.value.find(d => d.isFirstDayOfMonth) | ||||
|   const firstDayOfMonth = days.value.find((d) => d.isFirstDayOfMonth) | ||||
|   if (!firstDayOfMonth) return null | ||||
|  | ||||
|   const month = firstDayOfMonth.month | ||||
| @@ -60,7 +67,7 @@ const monthLabel = computed(() => { | ||||
|   return { | ||||
|     name: getLocalizedMonthName(month), | ||||
|     year: String(year).slice(-2), | ||||
|     weeksSpan | ||||
|     weeksSpan, | ||||
|   } | ||||
| }) | ||||
| </script> | ||||
|   | ||||
| @@ -33,7 +33,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed, ref } from 'vue' | ||||
| import { computed, ref, watch } from 'vue' | ||||
| import { | ||||
|   getLocalizedWeekdayNames, | ||||
|   getLocaleFirstDay, | ||||
| @@ -44,7 +44,10 @@ import { | ||||
| const model = defineModel({ | ||||
|   type: Array, | ||||
|   default: () => [false, false, false, false, false, false, false], | ||||
| }) | ||||
| }) // external value consumers see | ||||
|  | ||||
| // Internal state preserves the user's explicit picks even if all false | ||||
| const internal = ref([false, false, false, false, false, false, false]) | ||||
|  | ||||
| const props = defineProps({ | ||||
|   weekend: { type: Array, default: undefined }, | ||||
| @@ -55,12 +58,11 @@ const props = defineProps({ | ||||
|   firstDay: { type: Number, default: null }, | ||||
| }) | ||||
|  | ||||
| // If external model provided is entirely false, keep as-is (user will see fallback styling), | ||||
| // only overwrite if null/undefined. | ||||
| if (!model.value) model.value = [...props.fallback] | ||||
| // Initialize internal from external if it has any true; else keep empty (fallback handled on emit) | ||||
| if (model.value?.some?.(Boolean)) internal.value = [...model.value] | ||||
| const labelsMondayFirst = getLocalizedWeekdayNames() | ||||
| const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)] | ||||
| const anySelected = computed(() => model.value.some(Boolean)) | ||||
| const anySelected = computed(() => internal.value.some(Boolean)) | ||||
| const localeFirst = getLocaleFirstDay() | ||||
| const localeWeekend = getLocaleWeekendDays() | ||||
| const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7) | ||||
| @@ -71,10 +73,38 @@ const weekendDays = computed(() => { | ||||
| }) | ||||
|  | ||||
| const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value)) | ||||
| const displayValuesCommitted = computed(() => reorderByFirstDay(model.value, firstDay.value)) | ||||
| const displayValuesCommitted = computed(() => reorderByFirstDay(internal.value, firstDay.value)) | ||||
| const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value)) | ||||
| const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value)) | ||||
|  | ||||
| // Expose a normalized pattern (Sunday-first) that substitutes the fallback day if none selected. | ||||
| // This keeps UI visually showing fallback (muted) but downstream logic can opt-in by reading this. | ||||
| function computeFallbackPattern() { | ||||
|   const fb = props.fallback && props.fallback.length === 7 ? props.fallback : null | ||||
|   if (fb && fb.some(Boolean)) return [...fb] | ||||
|   const arr = [false, false, false, false, false, false, false] | ||||
|   const idx = fb ? fb.findIndex(Boolean) : -1 | ||||
|   if (idx >= 0) arr[idx] = true | ||||
|   else arr[0] = true | ||||
|   return arr | ||||
| } | ||||
| function emitExternal() { | ||||
|   model.value = internal.value.some(Boolean) ? [...internal.value] : computeFallbackPattern() | ||||
| } | ||||
| emitExternal() | ||||
| watch( | ||||
|   () => model.value, | ||||
|   (nv) => { | ||||
|     if (!nv) return | ||||
|     if (!nv.some(Boolean)) return | ||||
|     const fb = computeFallbackPattern() | ||||
|     const isFallback = fb.every((v, i) => v === nv[i]) | ||||
|     // If internal is empty and model only reflects fallback, do not sync into internal | ||||
|     if (isFallback && !internal.value.some(Boolean)) return | ||||
|     internal.value = [...nv] | ||||
|   }, | ||||
| ) | ||||
|  | ||||
| // Mapping from display index to original model index | ||||
| const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7)) | ||||
|  | ||||
| @@ -135,8 +165,8 @@ function isPressing(di) { | ||||
| } | ||||
|  | ||||
| function onPointerDown(di) { | ||||
|   originalValues = [...model.value] | ||||
|   dragVal.value = !model.value[(di + firstDay.value) % 7] | ||||
|   originalValues = [...internal.value] | ||||
|   dragVal.value = !internal.value[(di + firstDay.value) % 7] | ||||
|   dragStart.value = di | ||||
|   previewEnd.value = di | ||||
|   dragging.value = true | ||||
| @@ -155,7 +185,8 @@ function onPointerUp() { | ||||
|     // simple click: toggle single | ||||
|     const next = [...originalValues] | ||||
|     next[(dragStart.value + firstDay.value) % 7] = dragVal.value | ||||
|     model.value = next | ||||
|     internal.value = next | ||||
|     emitExternal() | ||||
|     cleanupDrag() | ||||
|   } else { | ||||
|     commitDrag() | ||||
| @@ -169,7 +200,8 @@ function commitDrag() { | ||||
|       : [previewEnd.value, dragStart.value] | ||||
|   const next = [...originalValues] | ||||
|   for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value | ||||
|   model.value = next | ||||
|   internal.value = next | ||||
|   emitExternal() | ||||
|   cleanupDrag() | ||||
| } | ||||
| function cancelDrag() { | ||||
| @@ -185,14 +217,15 @@ function cleanupDrag() { | ||||
| function toggleWeekend(work) { | ||||
|   const base = weekendDays.value | ||||
|   const target = work ? base : base.map((v) => !v) | ||||
|   const current = model.value | ||||
|   const current = internal.value | ||||
|   const allOn = current.every(Boolean) | ||||
|   const isTargetActive = current.every((v, i) => v === target[i]) | ||||
|   if (allOn || isTargetActive) { | ||||
|     model.value = [false, false, false, false, false, false, false] | ||||
|     internal.value = [false, false, false, false, false, false, false] | ||||
|   } else { | ||||
|     model.value = [...target] | ||||
|     internal.value = [...target] | ||||
|   } | ||||
|   emitExternal() | ||||
| } | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -2,11 +2,17 @@ import './assets/calendar.css' | ||||
|  | ||||
| import { createApp } from 'vue' | ||||
| import { createPinia } from 'pinia' | ||||
| import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' | ||||
| import { calendarHistory } from '@/plugins/calendarHistory' | ||||
|  | ||||
| import App from './App.vue' | ||||
|  | ||||
| const app = createApp(App) | ||||
|  | ||||
| app.use(createPinia()) | ||||
| const pinia = createPinia() | ||||
| // Order: persistence first so snapshots recorded by undo reflect already-hydrated state | ||||
| pinia.use(piniaPluginPersistedstate) | ||||
| pinia.use(calendarHistory) | ||||
| app.use(pinia) | ||||
|  | ||||
| app.mount('#app') | ||||
|   | ||||
							
								
								
									
										200
									
								
								src/plugins/calendarHistory.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								src/plugins/calendarHistory.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,200 @@ | ||||
| // Custom lightweight undo/redo specifically for calendar store with Map support | ||||
| // Adds store.$history = { undo(), redo(), canUndo, canRedo, clear(), pushManual() } | ||||
| // Wraps action calls to create history entries only for meaningful mutations. | ||||
|  | ||||
| function deepCloneCalendarState(raw) { | ||||
|   // We only need to snapshot keys we care about; omit volatile fields | ||||
|   const { today, events, config, weekend } = raw | ||||
|   return { | ||||
|     today, | ||||
|     weekend: Array.isArray(weekend) ? [...weekend] : weekend, | ||||
|     config: JSON.parse(JSON.stringify(config)), | ||||
|     events: new Map([...events].map(([k, v]) => [k, { ...v }])), | ||||
|   } | ||||
| } | ||||
|  | ||||
| function restoreCalendarState(store, snap) { | ||||
|   store.today = snap.today | ||||
|   store.weekend = Array.isArray(snap.weekend) ? [...snap.weekend] : snap.weekend | ||||
|   store.config = JSON.parse(JSON.stringify(snap.config)) | ||||
|   store.events = new Map([...snap.events].map(([k, v]) => [k, { ...v }])) | ||||
|   store.eventsMutation = (store.eventsMutation + 1) % 1_000_000_000 | ||||
| } | ||||
|  | ||||
| export function calendarHistory({ store }) { | ||||
|   if (store.$id !== 'calendar') return | ||||
|  | ||||
|   const max = 100 // history depth limit | ||||
|   const history = [] // past states | ||||
|   let pointer = -1 // index of current state in history | ||||
|   let isRestoring = false | ||||
|   let lastSerialized = null | ||||
|   // Compound editing session (e.g. event dialog) flags | ||||
|   let compoundActive = false | ||||
|   let compoundBaseSig = null | ||||
|   let compoundChanged = false | ||||
|  | ||||
|   function serializeForComparison() { | ||||
|     const evCount = store.events instanceof Map ? store.events.size : 0 | ||||
|     const em = store.eventsMutation || 0 | ||||
|     return `${em}|${evCount}|${store.today}|${JSON.stringify(store.config)}` | ||||
|   } | ||||
|  | ||||
|   function pushSnapshot() { | ||||
|     if (isRestoring) return | ||||
|     const sig = serializeForComparison() | ||||
|     if (sig === lastSerialized) return | ||||
|     // Drop any redo branch | ||||
|     if (pointer < history.length - 1) history.splice(pointer + 1) | ||||
|     history.push(deepCloneCalendarState(store)) | ||||
|     if (history.length > max) history.shift() | ||||
|     pointer = history.length - 1 | ||||
|     lastSerialized = sig | ||||
|     bumpIndicators() | ||||
|     // console.log('[history] pushed', pointer, sig) | ||||
|   } | ||||
|  | ||||
|   function bumpIndicators() { | ||||
|     if (typeof store.historyTick === 'number') { | ||||
|       store.historyTick = (store.historyTick + 1) % 1_000_000_000 | ||||
|     } | ||||
|     if (typeof store.historyCanUndo === 'boolean') { | ||||
|       store.historyCanUndo = pointer > 0 | ||||
|     } | ||||
|     if (typeof store.historyCanRedo === 'boolean') { | ||||
|       store.historyCanRedo = pointer >= 0 && pointer < history.length - 1 | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function markPotentialChange() { | ||||
|     if (isRestoring) return | ||||
|     if (compoundActive) { | ||||
|       const sig = serializeForComparison() | ||||
|       if (sig !== compoundBaseSig) compoundChanged = true | ||||
|       return | ||||
|     } | ||||
|     pushSnapshot() | ||||
|   } | ||||
|  | ||||
|   function beginCompound() { | ||||
|     if (compoundActive) return | ||||
|     compoundActive = true | ||||
|     compoundBaseSig = serializeForComparison() | ||||
|     compoundChanged = false | ||||
|   } | ||||
|   function endCompound() { | ||||
|     if (!compoundActive) return | ||||
|     const finalSig = serializeForComparison() | ||||
|     const changed = compoundChanged || finalSig !== compoundBaseSig | ||||
|     compoundActive = false | ||||
|     compoundBaseSig = null | ||||
|     if (changed) pushSnapshot() | ||||
|     else bumpIndicators() // session ended without change – still refresh flags | ||||
|   } | ||||
|  | ||||
|   function undo() { | ||||
|     // Ensure any active compound changes are finalized before moving back | ||||
|     if (compoundActive) endCompound() | ||||
|     else { | ||||
|       // If current state differs from last snapshot, push it so redo can restore it | ||||
|       const curSig = serializeForComparison() | ||||
|       if (curSig !== lastSerialized) pushSnapshot() | ||||
|     } | ||||
|     if (pointer <= 0) return | ||||
|     pointer-- | ||||
|     isRestoring = true | ||||
|     try { | ||||
|       restoreCalendarState(store, history[pointer]) | ||||
|       lastSerialized = serializeForComparison() | ||||
|     } finally { | ||||
|       isRestoring = false | ||||
|     } | ||||
|     bumpIndicators() | ||||
|   } | ||||
|  | ||||
|   function redo() { | ||||
|     if (compoundActive) endCompound() | ||||
|     else { | ||||
|       const curSig = serializeForComparison() | ||||
|       if (curSig !== lastSerialized) pushSnapshot() | ||||
|     } | ||||
|     if (pointer >= history.length - 1) return | ||||
|     pointer++ | ||||
|     isRestoring = true | ||||
|     try { | ||||
|       restoreCalendarState(store, history[pointer]) | ||||
|       lastSerialized = serializeForComparison() | ||||
|     } finally { | ||||
|       isRestoring = false | ||||
|     } | ||||
|     bumpIndicators() | ||||
|   } | ||||
|  | ||||
|   function clear() { | ||||
|     history.length = 0 | ||||
|     pointer = -1 | ||||
|     lastSerialized = null | ||||
|     bumpIndicators() | ||||
|   } | ||||
|  | ||||
|   // Wrap selected mutating actions to push snapshot AFTER they run if state changed. | ||||
|   const actionNames = [ | ||||
|     'createEvent', | ||||
|     'deleteEvent', | ||||
|     'deleteFirstOccurrence', | ||||
|     'deleteSingleOccurrence', | ||||
|     'deleteFromOccurrence', | ||||
|     'setEventRange', | ||||
|     'splitMoveVirtualOccurrence', | ||||
|     'splitRepeatSeries', | ||||
|     '_terminateRepeatSeriesAtIndex', | ||||
|     'toggleHolidays', | ||||
|     'initializeHolidays', | ||||
|   ] | ||||
|  | ||||
|   for (const name of actionNames) { | ||||
|     if (typeof store[name] === 'function') { | ||||
|       const original = store[name].bind(store) | ||||
|       store[name] = (...args) => { | ||||
|         const beforeSig = serializeForComparison() | ||||
|         const result = original(...args) | ||||
|         const afterSig = serializeForComparison() | ||||
|         if (afterSig !== beforeSig) markPotentialChange() | ||||
|         return result | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Capture direct property edits (e.g., deep field edits signaled via touchEvents()) | ||||
|   store.$subscribe((mutation, _state) => { | ||||
|     if (mutation.storeId !== 'calendar') return | ||||
|     markPotentialChange() | ||||
|   }) | ||||
|  | ||||
|   // Initial snapshot after hydration (next microtask to let persistence load) | ||||
|   Promise.resolve().then(() => pushSnapshot()) | ||||
|  | ||||
|   store.$history = { | ||||
|     undo, | ||||
|     redo, | ||||
|     clear, | ||||
|     pushManual: pushSnapshot, | ||||
|     beginCompound, | ||||
|     endCompound, | ||||
|     flush() { | ||||
|       pushSnapshot() | ||||
|     }, | ||||
|     get canUndo() { | ||||
|       return pointer > 0 | ||||
|     }, | ||||
|     get canRedo() { | ||||
|       return pointer >= 0 && pointer < history.length - 1 | ||||
|     }, | ||||
|     get compoundActive() { | ||||
|       return compoundActive | ||||
|     }, | ||||
|     _debug() { | ||||
|       return { pointer, length: history.length } | ||||
|     }, | ||||
|   } | ||||
| } | ||||
							
								
								
									
										57
									
								
								src/plugins/calendarUndoNormalize.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/plugins/calendarUndoNormalize.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| // Pinia plugin to ensure calendar store keeps Map for events after undo/redo snapshots | ||||
| export function calendarUndoNormalize({ store }) { | ||||
|   if (store.$id !== 'calendar') return | ||||
|  | ||||
|   function fixEvents() { | ||||
|     const evs = store.events | ||||
|     if (evs instanceof Map) return | ||||
|     // If serialized form { __map: true, data: [...] } | ||||
|     if (evs && evs.__map && Array.isArray(evs.data)) { | ||||
|       store.events = new Map(evs.data) | ||||
|       return | ||||
|     } | ||||
|     // If an array of [k,v] | ||||
|     if (Array.isArray(evs) && evs.every((x) => Array.isArray(x) && x.length === 2)) { | ||||
|       store.events = new Map(evs) | ||||
|       return | ||||
|     } | ||||
|     // If plain object, convert own enumerable props | ||||
|     if (evs && typeof evs === 'object') { | ||||
|       store.events = new Map(Object.entries(evs)) | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Patch undo/redo if present (after pinia-undo is installed) | ||||
|   const patchFns = ['undo', 'redo'] | ||||
|   for (const fn of patchFns) { | ||||
|     if (typeof store[fn] === 'function') { | ||||
|       const original = store[fn].bind(store) | ||||
|       store[fn] = (...args) => { | ||||
|         console.log(`[calendar history] ${fn} invoked`) | ||||
|         const beforeType = store.events && store.events.constructor && store.events.constructor.name | ||||
|         const out = original(...args) | ||||
|         const afterRawType = | ||||
|           store.events && store.events.constructor && store.events.constructor.name | ||||
|         fixEvents() | ||||
|         const finalType = store.events && store.events.constructor && store.events.constructor.name | ||||
|         let size = null | ||||
|         try { | ||||
|           if (store.events instanceof Map) size = store.events.size | ||||
|           else if (Array.isArray(store.events)) size = store.events.length | ||||
|         } catch {} | ||||
|         console.log( | ||||
|           `[calendar history] ${fn} types: before=${beforeType} afterRaw=${afterRawType} final=${finalType} size=${size}`, | ||||
|         ) | ||||
|         return out | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Also watch all mutations (includes direct assigns and action commits) | ||||
|   store.$subscribe(() => { | ||||
|     fixEvents() | ||||
|   }) | ||||
|  | ||||
|   // Initial sanity | ||||
|   fixEvents() | ||||
| } | ||||
							
								
								
									
										24
									
								
								src/plugins/persist.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/plugins/persist.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| // Simple Pinia persistence plugin supporting `persist: true` and Map serialization. | ||||
| export function persistPlugin({ store }) { | ||||
|   if (!store.$options || !store.$options.persist) return | ||||
|   const key = `pinia-${store.$id}` | ||||
|   try { | ||||
|     const raw = localStorage.getItem(key) | ||||
|     if (raw) { | ||||
|       const state = JSON.parse(raw, (k, v) => { | ||||
|         if (v && v.__map === true && Array.isArray(v.data)) return new Map(v.data) | ||||
|         return v | ||||
|       }) | ||||
|       store.$patch(state) | ||||
|     } | ||||
|   } catch {} | ||||
|   store.$subscribe((_mutation, state) => { | ||||
|     try { | ||||
|       const json = JSON.stringify(state, (_k, v) => { | ||||
|         if (v instanceof Map) return { __map: true, data: Array.from(v.entries()) } | ||||
|         return v | ||||
|       }) | ||||
|       localStorage.setItem(key, json) | ||||
|     } catch {} | ||||
|   }) | ||||
| } | ||||
							
								
								
									
										331
									
								
								src/plugins/scrollManager.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										331
									
								
								src/plugins/scrollManager.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,331 @@ | ||||
| import { ref } from 'vue' | ||||
|  | ||||
| function createMomentumDrag({ | ||||
|   viewport, | ||||
|   viewportHeight, | ||||
|   contentHeight, | ||||
|   setScrollTop, | ||||
|   speed, | ||||
|   reasonDragPointer, | ||||
|   reasonDragTouch, | ||||
|   reasonMomentum, | ||||
|   allowTouch, | ||||
|   hitTest, | ||||
| }) { | ||||
|   let dragging = false | ||||
|   let startY = 0 | ||||
|   let startScroll = 0 | ||||
|   let velocity = 0 | ||||
|   let samples = [] // { timestamp, position } | ||||
|   let momentumActive = false | ||||
|   let momentumFrame = null | ||||
|   let dragAccumY = 0 // used when pointer lock active | ||||
|   let usingPointerLock = false | ||||
|   const frictionPerMs = 0.0018 | ||||
|   const MIN_V = 0.03 | ||||
|   const VELOCITY_MS = 50 | ||||
|  | ||||
|   function cancelMomentum() { | ||||
|     if (!momentumActive) return | ||||
|     momentumActive = false | ||||
|     if (momentumFrame) cancelAnimationFrame(momentumFrame) | ||||
|     momentumFrame = null | ||||
|   } | ||||
|   function startMomentum() { | ||||
|     if (Math.abs(velocity) < MIN_V) return | ||||
|     cancelMomentum() | ||||
|     momentumActive = true | ||||
|     let lastTs = performance.now() | ||||
|     const step = () => { | ||||
|       if (!momentumActive) return | ||||
|       const now = performance.now() | ||||
|       const dt = now - lastTs | ||||
|       lastTs = now | ||||
|       if (dt <= 0) { | ||||
|         momentumFrame = requestAnimationFrame(step) | ||||
|         return | ||||
|       } | ||||
|       const decay = Math.exp(-frictionPerMs * dt) | ||||
|       velocity *= decay | ||||
|       const delta = velocity * dt | ||||
|       if (viewport.value) { | ||||
|         let cur = viewport.value.scrollTop | ||||
|         let target = cur + delta | ||||
|         const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) | ||||
|         if (target < 0) { | ||||
|           target = 0 | ||||
|           velocity = 0 | ||||
|         } else if (target > maxScroll) { | ||||
|           target = maxScroll | ||||
|           velocity = 0 | ||||
|         } | ||||
|         setScrollTop(target, reasonMomentum) | ||||
|       } | ||||
|       if (Math.abs(velocity) < MIN_V * 0.6) { | ||||
|         momentumActive = false | ||||
|         return | ||||
|       } | ||||
|       momentumFrame = requestAnimationFrame(step) | ||||
|     } | ||||
|     momentumFrame = requestAnimationFrame(step) | ||||
|   } | ||||
|   function applyDragByDelta(deltaY, reason) { | ||||
|     const now = performance.now() | ||||
|     while (samples[0]?.timestamp < now - VELOCITY_MS) samples.shift() | ||||
|     samples.push({ timestamp: now, position: deltaY }) | ||||
|     const newScrollTop = startScroll - deltaY * speed | ||||
|     const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) | ||||
|     const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) | ||||
|     setScrollTop(clamped, reason) | ||||
|   } | ||||
|   function applyDragPosition(clientY, reason) { | ||||
|     const deltaY = clientY - startY | ||||
|     applyDragByDelta(deltaY, reason) | ||||
|   } | ||||
|   function endDrag() { | ||||
|     if (!dragging) return | ||||
|     dragging = false | ||||
|     window.removeEventListener('pointermove', onPointerMove, true) | ||||
|     window.removeEventListener('pointerup', endDrag, true) | ||||
|     window.removeEventListener('pointercancel', endDrag, true) | ||||
|     if (allowTouch) { | ||||
|       window.removeEventListener('touchmove', onTouchMove) | ||||
|       window.removeEventListener('touchend', endDrag) | ||||
|       window.removeEventListener('touchcancel', endDrag) | ||||
|     } | ||||
|     document.removeEventListener('pointerlockchange', onPointerLockChange, true) | ||||
|     if (usingPointerLock && document.pointerLockElement === viewport.value) { | ||||
|       try { | ||||
|         document.exitPointerLock() | ||||
|       } catch {} | ||||
|     } | ||||
|     usingPointerLock = false | ||||
|     velocity = 0 | ||||
|     if (samples.length) { | ||||
|       const first = samples[0] | ||||
|       const now = performance.now() | ||||
|       const last = samples[samples.length - 1] | ||||
|       const dy = last.position - first.position | ||||
|       if (Math.abs(dy) > 5) velocity = (-dy * speed) / (now - first.timestamp) | ||||
|     } | ||||
|     samples = [] | ||||
|     startMomentum() | ||||
|   } | ||||
|   function onPointerMove(e) { | ||||
|     if (!dragging || document.pointerLockElement !== viewport.value) return | ||||
|     dragAccumY += e.movementY | ||||
|     applyDragByDelta(dragAccumY, reasonDragPointer) | ||||
|     e.preventDefault() | ||||
|   } | ||||
|   function onTouchMove(e) { | ||||
|     if (!dragging) return | ||||
|     if (e.touches.length !== 1) { | ||||
|       endDrag() | ||||
|       return | ||||
|     } | ||||
|     applyDragPosition(e.touches[0].clientY, reasonDragTouch) | ||||
|     e.preventDefault() | ||||
|   } | ||||
|   function handlePointerDown(e) { | ||||
|     if (e.button !== undefined && e.button !== 0) return | ||||
|     if (hitTest && !hitTest(e)) return | ||||
|     e.preventDefault() | ||||
|     cancelMomentum() | ||||
|     dragging = true | ||||
|     startY = e.clientY | ||||
|     startScroll = viewport.value?.scrollTop || 0 | ||||
|     velocity = 0 | ||||
|     dragAccumY = 0 | ||||
|     samples = [{ timestamp: performance.now(), position: e.clientY }] | ||||
|     window.addEventListener('pointermove', onPointerMove, true) | ||||
|     window.addEventListener('pointerup', endDrag, true) | ||||
|     window.addEventListener('pointercancel', endDrag, true) | ||||
|     document.addEventListener('pointerlockchange', onPointerLockChange, true) | ||||
|     viewport.value.requestPointerLock({ unadjustedMovement: true }) | ||||
|   } | ||||
|   function handleTouchStart(e) { | ||||
|     if (!allowTouch) return | ||||
|     if (e.touches.length !== 1) return | ||||
|     if (hitTest && !hitTest(e.touches[0])) return | ||||
|     cancelMomentum() | ||||
|     dragging = true | ||||
|     const t = e.touches[0] | ||||
|     startY = t.clientY | ||||
|     startScroll = viewport.value?.scrollTop || 0 | ||||
|     velocity = 0 | ||||
|     dragAccumY = 0 | ||||
|     samples = [{ timestamp: performance.now(), position: t.clientY }] | ||||
|     window.addEventListener('touchmove', onTouchMove, { passive: false }) | ||||
|     window.addEventListener('touchend', endDrag, { passive: false }) | ||||
|     window.addEventListener('touchcancel', endDrag, { passive: false }) | ||||
|     e.preventDefault() | ||||
|   } | ||||
|   function onPointerLockChange() { | ||||
|     const lockedEl = document.pointerLockElement | ||||
|     if (dragging && lockedEl === viewport.value) { | ||||
|       usingPointerLock = true | ||||
|       return | ||||
|     } | ||||
|     if (dragging && usingPointerLock && lockedEl !== viewport.value) endDrag() | ||||
|     if (!dragging) usingPointerLock = false | ||||
|   } | ||||
|   return { handlePointerDown, handleTouchStart, cancelMomentum } | ||||
| } | ||||
|  | ||||
| export function createScrollManager({ viewport, scheduleRebuild }) { | ||||
|   const scrollTop = ref(0) | ||||
|   let lastProgrammatic = null | ||||
|   let pendingTarget = null | ||||
|   let pendingAttempts = 0 | ||||
|   let pendingLoopActive = false | ||||
|  | ||||
|   function setScrollTop(val, reason = 'programmatic') { | ||||
|     let applied = val | ||||
|     if (viewport.value) { | ||||
|       const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight | ||||
|       if (applied > maxScroll) { | ||||
|         applied = maxScroll < 0 ? 0 : maxScroll | ||||
|         pendingTarget = val | ||||
|         pendingAttempts = 0 | ||||
|         startPendingLoop() | ||||
|       } | ||||
|       if (applied < 0) applied = 0 | ||||
|       viewport.value.scrollTop = applied | ||||
|     } | ||||
|     scrollTop.value = applied | ||||
|     lastProgrammatic = applied | ||||
|     scheduleRebuild(reason) | ||||
|   } | ||||
|  | ||||
|   function onScroll() { | ||||
|     if (!viewport.value) return | ||||
|     const cur = viewport.value.scrollTop | ||||
|     const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight | ||||
|     let effective = cur | ||||
|     if (cur < 0) effective = 0 | ||||
|     else if (cur > maxScroll) effective = maxScroll | ||||
|     scrollTop.value = effective | ||||
|     if (lastProgrammatic !== null && effective === lastProgrammatic) { | ||||
|       lastProgrammatic = null | ||||
|       return | ||||
|     } | ||||
|     if (pendingTarget !== null && Math.abs(effective - pendingTarget) > 4) { | ||||
|       pendingTarget = null | ||||
|     } | ||||
|     scheduleRebuild('scroll') | ||||
|   } | ||||
|  | ||||
|   function startPendingLoop() { | ||||
|     if (pendingLoopActive || !viewport.value) return | ||||
|     pendingLoopActive = true | ||||
|     const loop = () => { | ||||
|       if (pendingTarget == null || !viewport.value) { | ||||
|         pendingLoopActive = false | ||||
|         return | ||||
|       } | ||||
|       const maxScroll = viewport.value.scrollHeight - viewport.value.clientHeight | ||||
|       if (pendingTarget <= maxScroll) { | ||||
|         setScrollTop(pendingTarget, 'pending-fulfill') | ||||
|         pendingTarget = null | ||||
|         pendingLoopActive = false | ||||
|         return | ||||
|       } | ||||
|       pendingAttempts++ | ||||
|       if (pendingAttempts > 120) { | ||||
|         pendingTarget = null | ||||
|         pendingLoopActive = false | ||||
|         return | ||||
|       } | ||||
|       requestAnimationFrame(loop) | ||||
|     } | ||||
|     requestAnimationFrame(loop) | ||||
|   } | ||||
|  | ||||
|   return { scrollTop, setScrollTop, onScroll } | ||||
| } | ||||
|  | ||||
| export function createWeekColumnScrollManager({ | ||||
|   viewport, | ||||
|   viewportHeight, | ||||
|   contentHeight, | ||||
|   setScrollTop, | ||||
| }) { | ||||
|   const isWeekColDragging = ref(false) | ||||
|   function getWeekLabelRect() { | ||||
|     const headerYear = document.querySelector('.calendar-header .year-label') | ||||
|     if (headerYear) return headerYear.getBoundingClientRect() | ||||
|     const weekLabel = viewport.value?.querySelector('.week-row .week-label') | ||||
|     return weekLabel ? weekLabel.getBoundingClientRect() : null | ||||
|   } | ||||
|   const drag = createMomentumDrag({ | ||||
|     viewport, | ||||
|     viewportHeight, | ||||
|     contentHeight, | ||||
|     setScrollTop, | ||||
|     speed: 1, | ||||
|     reasonDragPointer: 'week-col-drag', | ||||
|     reasonDragTouch: 'week-col-drag', | ||||
|     reasonMomentum: 'week-col-momentum', | ||||
|     allowTouch: false, | ||||
|     hitTest: (e) => { | ||||
|       const rect = getWeekLabelRect() | ||||
|       if (!rect) return false | ||||
|       const x = e.clientX ?? e.pageX | ||||
|       return x >= rect.left && x <= rect.right | ||||
|     }, | ||||
|   }) | ||||
|   function handleWeekColMouseDown(e) { | ||||
|     if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return | ||||
|     isWeekColDragging.value = true | ||||
|     drag.handlePointerDown(e) | ||||
|     const end = () => { | ||||
|       isWeekColDragging.value = false | ||||
|       window.removeEventListener('pointerup', end, true) | ||||
|       window.removeEventListener('pointercancel', end, true) | ||||
|     } | ||||
|     window.addEventListener('pointerup', end, true) | ||||
|     window.addEventListener('pointercancel', end, true) | ||||
|   } | ||||
|   function handlePointerLockChange() { | ||||
|     if (document.pointerLockElement !== viewport.value) { | ||||
|       isWeekColDragging.value = false | ||||
|     } | ||||
|   } | ||||
|   return { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } | ||||
| } | ||||
|  | ||||
| export function createMonthScrollManager({ | ||||
|   viewport, | ||||
|   viewportHeight, | ||||
|   contentHeight, | ||||
|   setScrollTop, | ||||
| }) { | ||||
|   const drag = createMomentumDrag({ | ||||
|     viewport, | ||||
|     viewportHeight, | ||||
|     contentHeight, | ||||
|     setScrollTop, | ||||
|     speed: 10, | ||||
|     reasonDragPointer: 'month-scroll-drag', | ||||
|     reasonDragTouch: 'month-scroll-touch', | ||||
|     reasonMomentum: 'month-scroll-momentum', | ||||
|     allowTouch: true, | ||||
|     hitTest: null, | ||||
|   }) | ||||
|   function handleMonthScrollPointerDown(e) { | ||||
|     drag.handlePointerDown(e) | ||||
|   } | ||||
|   function handleMonthScrollTouchStart(e) { | ||||
|     drag.handleTouchStart(e) | ||||
|   } | ||||
|   function handleMonthScrollWheel(e) { | ||||
|     drag.cancelMomentum() | ||||
|     const currentScroll = viewport.value?.scrollTop || 0 | ||||
|     const newScrollTop = currentScroll + e.deltaY * 10 | ||||
|     const maxScroll = Math.max(0, contentHeight.value - viewportHeight.value) | ||||
|     const clamped = Math.max(0, Math.min(newScrollTop, maxScroll)) | ||||
|     setScrollTop(clamped, 'month-scroll-wheel') | ||||
|     e.preventDefault() | ||||
|   } | ||||
|   return { handleMonthScrollPointerDown, handleMonthScrollTouchStart, handleMonthScrollWheel } | ||||
| } | ||||
							
								
								
									
										400
									
								
								src/plugins/virtualWeeks.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										400
									
								
								src/plugins/virtualWeeks.js
									
									
									
									
									
										Normal 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, | ||||
|   } | ||||
| } | ||||
| @@ -2,76 +2,113 @@ import { defineStore } from 'pinia' | ||||
| import { | ||||
|   toLocalString, | ||||
|   fromLocalString, | ||||
|   getLocaleFirstDay, | ||||
|   getLocaleWeekendDays, | ||||
|   getMondayOfISOWeek, | ||||
|   getOccurrenceDate, | ||||
|   DEFAULT_TZ, | ||||
| } from '@/utils/date' | ||||
|  | ||||
| /** | ||||
|  * Calendar configuration can be overridden via window.calendarConfig: | ||||
|  *  | ||||
|  * window.calendarConfig = { | ||||
|  *   firstDay: 0,           // 0=Sunday, 1=Monday, etc. (default: 1) | ||||
|  *   firstDay: 'auto',      // Use locale detection | ||||
|  *   weekendDays: [true, false, false, false, false, false, true], // Custom weekend | ||||
|  *   weekendDays: 'auto'    // Use locale detection (default) | ||||
|  * } | ||||
|  */ | ||||
|  | ||||
| const MIN_YEAR = 1900 | ||||
| const MAX_YEAR = 2100 | ||||
|  | ||||
| // Helper function to determine first day with config override support | ||||
| function getConfiguredFirstDay() { | ||||
|   // Check for environment variable or global config | ||||
|   const configOverride = window?.calendarConfig?.firstDay | ||||
|   if (configOverride !== undefined) { | ||||
|     return configOverride === 'auto' ? getLocaleFirstDay() : Number(configOverride) | ||||
|   } | ||||
|   // Default to Monday (1) instead of locale | ||||
|   return 1 | ||||
| } | ||||
|  | ||||
| // Helper function to determine weekend days with config override support   | ||||
| function getConfiguredWeekendDays() { | ||||
|   // Check for environment variable or global config | ||||
|   const configOverride = window?.calendarConfig?.weekendDays | ||||
|   if (configOverride !== undefined) { | ||||
|     return configOverride === 'auto' ? getLocaleWeekendDays() : configOverride | ||||
|   } | ||||
|   // Default to locale-based weekend days | ||||
|   return getLocaleWeekendDays() | ||||
| } | ||||
| import { differenceInCalendarDays, addDays } from 'date-fns' | ||||
| import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays' | ||||
|  | ||||
| export const useCalendarStore = defineStore('calendar', { | ||||
|   state: () => ({ | ||||
|     today: toLocalString(new Date()), | ||||
|     now: new Date(), | ||||
|     events: new Map(), // Map of date strings to arrays of events | ||||
|     weekend: getConfiguredWeekendDays(), | ||||
|     today: toLocalString(new Date(), DEFAULT_TZ), | ||||
|     now: new Date().toISOString(), | ||||
|     events: new Map(), | ||||
|     // Lightweight mutation counter so views can rebuild in a throttled / idle way | ||||
|     // without tracking deep reactivity on every event object. | ||||
|     eventsMutation: 0, | ||||
|     // Incremented internally by history plugin to force reactive updates for canUndo/canRedo | ||||
|     historyTick: 0, | ||||
|     historyCanUndo: false, | ||||
|     historyCanRedo: false, | ||||
|     weekend: getLocaleWeekendDays(), | ||||
|     _holidayConfigSignature: null, | ||||
|     _holidaysInitialized: false, | ||||
|     config: { | ||||
|       select_days: 1000, | ||||
|       min_year: MIN_YEAR, | ||||
|       max_year: MAX_YEAR, | ||||
|       first_day: getConfiguredFirstDay(), | ||||
|       select_days: 14, | ||||
|       first_day: 1, | ||||
|       holidays: { | ||||
|         enabled: true, | ||||
|         country: 'auto', | ||||
|         state: null, | ||||
|         region: null, | ||||
|       }, | ||||
|     }, | ||||
|   }), | ||||
|  | ||||
|   getters: { | ||||
|     // Basic configuration getters | ||||
|     minYear: () => MIN_YEAR, | ||||
|     maxYear: () => MAX_YEAR, | ||||
|   }, | ||||
|  | ||||
|   actions: { | ||||
|     updateCurrentDate() { | ||||
|       this.now = new Date() | ||||
|       const today = toLocalString(this.now) | ||||
|       if (this.today !== today) { | ||||
|         this.today = today | ||||
|       } | ||||
|     _rotateWeekdayPattern(pattern, shift) { | ||||
|       const k = (7 - (shift % 7)) % 7 | ||||
|       return pattern.slice(k).concat(pattern.slice(0, k)) | ||||
|     }, | ||||
|     _resolveCountry(code) { | ||||
|       if (!code || code !== 'auto') return code | ||||
|       const locale = navigator.language || navigator.languages?.[0] | ||||
|       if (!locale) return null | ||||
|       const parts = locale.split('-') | ||||
|       if (parts.length < 2) return null | ||||
|       return parts[parts.length - 1].toUpperCase() | ||||
|     }, | ||||
|  | ||||
|     initializeHolidaysFromConfig() { | ||||
|       if (!this.config.holidays.enabled) return false | ||||
|       const country = this._resolveCountry(this.config.holidays.country) | ||||
|       if (country) { | ||||
|         return this.initializeHolidays( | ||||
|           country, | ||||
|           this.config.holidays.state, | ||||
|           this.config.holidays.region, | ||||
|         ) | ||||
|       } | ||||
|       return false | ||||
|     }, | ||||
|  | ||||
|     updateCurrentDate() { | ||||
|       const d = new Date() | ||||
|       this.now = d.toISOString() | ||||
|       const today = toLocalString(d, DEFAULT_TZ) | ||||
|       if (this.today !== today) this.today = today | ||||
|     }, | ||||
|  | ||||
|     initializeHolidays(country, state = null, region = null) { | ||||
|       const actualCountry = this._resolveCountry(country) | ||||
|       if (this.config.holidays.country !== 'auto') this.config.holidays.country = country | ||||
|       this.config.holidays.state = state | ||||
|       this.config.holidays.region = region | ||||
|       this._holidayConfigSignature = null | ||||
|       this._holidaysInitialized = false | ||||
|       return initializeHolidays(actualCountry, state, region) | ||||
|     }, | ||||
|  | ||||
|     _ensureHolidaysInitialized() { | ||||
|       if (!this.config.holidays.enabled) return false | ||||
|       const actualCountry = this._resolveCountry(this.config.holidays.country) | ||||
|       const sig = `${actualCountry}-${this.config.holidays.state}-${this.config.holidays.region}-${this.config.holidays.enabled}` | ||||
|       if (this._holidayConfigSignature !== sig || !this._holidaysInitialized) { | ||||
|         const ok = initializeHolidays( | ||||
|           actualCountry, | ||||
|           this.config.holidays.state, | ||||
|           this.config.holidays.region, | ||||
|         ) | ||||
|         if (ok) { | ||||
|           this._holidayConfigSignature = sig | ||||
|           this._holidaysInitialized = true | ||||
|         } | ||||
|         return ok | ||||
|       } | ||||
|       return this._holidaysInitialized | ||||
|     }, | ||||
|  | ||||
|     getAvailableCountries() { | ||||
|       return getAvailableCountries() || [] | ||||
|     }, | ||||
|     getAvailableStates(country) { | ||||
|       return getAvailableStates(country) || [] | ||||
|     }, | ||||
|     toggleHolidays() { | ||||
|       this.config.holidays.enabled = !this.config.holidays.enabled | ||||
|     }, | ||||
|  | ||||
|     // Event management | ||||
|     generateId() { | ||||
|       try { | ||||
|         if (window.crypto && typeof window.crypto.randomUUID === 'function') { | ||||
| @@ -81,357 +118,308 @@ export const useCalendarStore = defineStore('calendar', { | ||||
|       return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36) | ||||
|     }, | ||||
|  | ||||
|     notifyEventsChanged() { | ||||
|       // Bump simple counter (wrapping to avoid overflow in extreme long sessions) | ||||
|       this.eventsMutation = (this.eventsMutation + 1) % 1_000_000_000 | ||||
|     }, | ||||
|     touchEvents() { | ||||
|       this.notifyEventsChanged() | ||||
|     }, | ||||
|  | ||||
|     createEvent(eventData) { | ||||
|       const singleDay = eventData.startDate === eventData.endDate | ||||
|       let days = 1 | ||||
|       if (typeof eventData.days === 'number') { | ||||
|         days = Math.max(1, Math.floor(eventData.days)) | ||||
|       } | ||||
|       const singleDay = days === 1 | ||||
|       const event = { | ||||
|         id: this.generateId(), | ||||
|         title: eventData.title, | ||||
|         startDate: eventData.startDate, | ||||
|         endDate: eventData.endDate, | ||||
|         days, | ||||
|         colorId: | ||||
|           eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), | ||||
|           eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.startDate), | ||||
|         startTime: singleDay ? eventData.startTime || '09:00' : null, | ||||
|         durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, | ||||
|         repeat: | ||||
|           (eventData.repeat === 'weekly' | ||||
|             ? 'weeks' | ||||
|             : eventData.repeat === 'monthly' | ||||
|               ? 'months' | ||||
|               : eventData.repeat) || 'none', | ||||
|         repeatInterval: eventData.repeatInterval || 1, | ||||
|         repeatCount: eventData.repeatCount || 'unlimited', | ||||
|         repeatWeekdays: eventData.repeatWeekdays, | ||||
|         isRepeating: eventData.repeat && eventData.repeat !== 'none', | ||||
|         recur: | ||||
|           eventData.recur && ['weeks', 'months'].includes(eventData.recur.freq) | ||||
|             ? { | ||||
|                 freq: eventData.recur.freq, | ||||
|                 interval: eventData.recur.interval || 1, | ||||
|                 count: eventData.recur.count ?? 'unlimited', | ||||
|                 weekdays: Array.isArray(eventData.recur.weekdays) | ||||
|                   ? [...eventData.recur.weekdays] | ||||
|                   : null, | ||||
|               } | ||||
|  | ||||
|       const startDate = new Date(fromLocalString(event.startDate)) | ||||
|       const endDate = new Date(fromLocalString(event.endDate)) | ||||
|  | ||||
|       for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { | ||||
|         const dateStr = toLocalString(d) | ||||
|         if (!this.events.has(dateStr)) { | ||||
|           this.events.set(dateStr, []) | ||||
|             : null, | ||||
|       } | ||||
|         this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate }) | ||||
|       } | ||||
|       // No physical expansion; repeats are virtual | ||||
|       this.events.set(event.id, { ...event, isSpanning: event.days > 1 }) | ||||
|       this.notifyEventsChanged() | ||||
|       return event.id | ||||
|     }, | ||||
|  | ||||
|     getEventById(id) { | ||||
|       for (const [, list] of this.events) { | ||||
|         const found = list.find((e) => e.id === id) | ||||
|         if (found) return found | ||||
|       } | ||||
|       return null | ||||
|       return this.events.get(id) || null | ||||
|     }, | ||||
|  | ||||
|     selectEventColorId(startDateStr, endDateStr) { | ||||
|       const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] | ||||
|       const startDate = new Date(fromLocalString(startDateStr)) | ||||
|       const endDate = new Date(fromLocalString(endDateStr)) | ||||
|  | ||||
|       for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { | ||||
|         const dateStr = toLocalString(d) | ||||
|         const dayEvents = this.events.get(dateStr) || [] | ||||
|         for (const event of dayEvents) { | ||||
|           if (event.colorId >= 0 && event.colorId < 8) { | ||||
|             colorCounts[event.colorId]++ | ||||
|       const startDate = fromLocalString(startDateStr, DEFAULT_TZ) | ||||
|       const endDate = fromLocalString(endDateStr, DEFAULT_TZ) | ||||
|       for (const ev of this.events.values()) { | ||||
|         const evStart = fromLocalString(ev.startDate) | ||||
|         const evEnd = addDays(evStart, (ev.days || 1) - 1) | ||||
|         if (evEnd < startDate || evStart > endDate) continue | ||||
|         if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++ | ||||
|       } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       let minCount = colorCounts[0] | ||||
|       let selectedColor = 0 | ||||
|  | ||||
|       for (let colorId = 1; colorId < 8; colorId++) { | ||||
|         if (colorCounts[colorId] < minCount) { | ||||
|           minCount = colorCounts[colorId] | ||||
|           selectedColor = colorId | ||||
|       for (let c = 1; c < 8; c++) { | ||||
|         if (colorCounts[c] < minCount) { | ||||
|           minCount = colorCounts[c] | ||||
|           selectedColor = c | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return selectedColor | ||||
|     }, | ||||
|  | ||||
|     deleteEvent(eventId) { | ||||
|       const datesToCleanup = [] | ||||
|       for (const [dateStr, eventList] of this.events) { | ||||
|         const eventIndex = eventList.findIndex((event) => event.id === eventId) | ||||
|         if (eventIndex !== -1) { | ||||
|           eventList.splice(eventIndex, 1) | ||||
|           if (eventList.length === 0) { | ||||
|             datesToCleanup.push(dateStr) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       datesToCleanup.forEach((dateStr) => this.events.delete(dateStr)) | ||||
|     }, | ||||
|  | ||||
|     deleteSingleOccurrence(ctx) { | ||||
|       const { baseId, occurrenceIndex } = ctx | ||||
|       const base = this.getEventById(baseId) | ||||
|       if (!base || base.repeat !== 'weekly') return | ||||
|       if (!base || base.repeat !== 'weeks') return | ||||
|       // Strategy: clone pattern into two: reduce base repeatCount to exclude this occurrence; create new series for remainder excluding this one | ||||
|       // Simpler: convert base to explicit exception by creating a one-off non-repeating event? For now: create exception by inserting a blocking dummy? Instead just split by shifting repeatCount logic: decrement repeatCount and create a new series starting after this single occurrence. | ||||
|       // Implementation (approx): terminate at occurrenceIndex; create a new series starting next occurrence with remaining occurrences. | ||||
|       const remaining = | ||||
|         base.repeatCount === 'unlimited' | ||||
|           ? 'unlimited' | ||||
|           : String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1))) | ||||
|       this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) | ||||
|       if (remaining === '0') return | ||||
|       // Find date of next occurrence | ||||
|       const startDate = new Date(base.startDate + 'T00:00:00') | ||||
|       let idx = 0 | ||||
|       let cur = new Date(startDate) | ||||
|       while (idx <= occurrenceIndex && idx < 10000) { | ||||
|         cur.setDate(cur.getDate() + 1) | ||||
|         if (base.repeatWeekdays[cur.getDay()]) idx++ | ||||
|       } | ||||
|       const nextStartStr = toLocalString(cur) | ||||
|       this.createEvent({ | ||||
|         title: base.title, | ||||
|         startDate: nextStartStr, | ||||
|         endDate: nextStartStr, | ||||
|         colorId: base.colorId, | ||||
|         repeat: 'weeks', | ||||
|         repeatCount: remaining, | ||||
|         repeatWeekdays: base.repeatWeekdays, | ||||
|       }) | ||||
|     }, | ||||
|  | ||||
|     deleteFromOccurrence(ctx) { | ||||
|       const { baseId, occurrenceIndex } = ctx | ||||
|       this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) | ||||
|       this.events.delete(eventId) | ||||
|       this.notifyEventsChanged() | ||||
|     }, | ||||
|  | ||||
|     deleteFirstOccurrence(baseId) { | ||||
|       const base = this.getEventById(baseId) | ||||
|       if (!base || !base.isRepeating) return | ||||
|       const oldStart = new Date(fromLocalString(base.startDate)) | ||||
|       const oldEnd = new Date(fromLocalString(base.endDate)) | ||||
|       const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000)) | ||||
|       let newStart = null | ||||
|  | ||||
|       if (base.repeat === 'weeks' && base.repeatWeekdays) { | ||||
|         const probe = new Date(oldStart) | ||||
|         for (let i = 0; i < 14; i++) { | ||||
|           // search ahead up to 2 weeks | ||||
|           probe.setDate(probe.getDate() + 1) | ||||
|           if (base.repeatWeekdays[probe.getDay()]) { | ||||
|             newStart = new Date(probe) | ||||
|             break | ||||
|           } | ||||
|         } | ||||
|       } else if (base.repeat === 'months') { | ||||
|         newStart = new Date(oldStart) | ||||
|         newStart.setMonth(newStart.getMonth() + 1) | ||||
|       } else { | ||||
|         // Unknown pattern: delete entire series | ||||
|       if (!base) return | ||||
|       if (!base.recur) { | ||||
|         this.deleteEvent(baseId) | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       if (!newStart) { | ||||
|         // No subsequent occurrence -> delete entire series | ||||
|       const numericCount = | ||||
|         base.recur.count === 'unlimited' ? Infinity : parseInt(base.recur.count, 10) | ||||
|       if (numericCount <= 1) { | ||||
|         this.deleteEvent(baseId) | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       if (base.repeatCount !== 'unlimited') { | ||||
|         const rc = parseInt(base.repeatCount, 10) | ||||
|         if (!isNaN(rc)) { | ||||
|           const newRc = Math.max(0, rc - 1) | ||||
|           if (newRc === 0) { | ||||
|       const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ) | ||||
|       if (!nextStartStr) { | ||||
|         this.deleteEvent(baseId) | ||||
|         return | ||||
|       } | ||||
|           base.repeatCount = String(newRc) | ||||
|         } | ||||
|       } | ||||
|       base.startDate = nextStartStr | ||||
|       // keep same days length | ||||
|       if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1)) | ||||
|       this.events.set(baseId, { ...base, isSpanning: base.days > 1 }) | ||||
|       this.notifyEventsChanged() | ||||
|     }, | ||||
|  | ||||
|       const newEnd = new Date(newStart) | ||||
|       newEnd.setDate(newEnd.getDate() + spanDays) | ||||
|       base.startDate = toLocalString(newStart) | ||||
|       base.endDate = toLocalString(newEnd) | ||||
|       // old occurrence expansion removed (series handled differently now) | ||||
|       const originalRepeatCount = base.repeatCount | ||||
|       // Always cap original series at the split occurrence index (occurrences 0..index-1) | ||||
|       // Keep its weekday pattern unchanged. | ||||
|       this._terminateRepeatSeriesAtIndex(baseId, index) | ||||
|  | ||||
|       let newRepeatCount = 'unlimited' | ||||
|       if (originalRepeatCount !== 'unlimited') { | ||||
|         const originalCount = parseInt(originalRepeatCount, 10) | ||||
|         if (!isNaN(originalCount)) { | ||||
|           const remaining = originalCount - index | ||||
|           // remaining occurrences go to new series; ensure at least 1 (the dragged occurrence itself) | ||||
|           newRepeatCount = remaining > 0 ? String(remaining) : '1' | ||||
|     deleteSingleOccurrence(ctx) { | ||||
|       const { baseId, occurrenceIndex } = ctx || {} | ||||
|       if (occurrenceIndex == null) return | ||||
|       const base = this.getEventById(baseId) | ||||
|       if (!base) return | ||||
|       if (!base.recur) { | ||||
|         if (occurrenceIndex === 0) this.deleteEvent(baseId) | ||||
|         return | ||||
|       } | ||||
|       } else { | ||||
|         // Original was unlimited: original now capped, new stays unlimited | ||||
|         newRepeatCount = 'unlimited' | ||||
|       if (occurrenceIndex === 0) { | ||||
|         this.deleteFirstOccurrence(baseId) | ||||
|         return | ||||
|       } | ||||
|  | ||||
|       // Handle weekdays for weekly repeats | ||||
|       let newRepeatWeekdays = base.repeatWeekdays | ||||
|       if (base.repeat === 'weeks' && base.repeatWeekdays) { | ||||
|         const newStartDate = new Date(fromLocalString(startDate)) | ||||
|         let dayShift = 0 | ||||
|         if (grabbedWeekday != null) { | ||||
|           // Rotate so that the grabbed weekday maps to the new start weekday | ||||
|           dayShift = newStartDate.getDay() - grabbedWeekday | ||||
|         } else { | ||||
|           // Fallback: rotate by difference between new and original start weekday | ||||
|           const originalStartDate = new Date(fromLocalString(base.startDate)) | ||||
|           dayShift = newStartDate.getDay() - originalStartDate.getDay() | ||||
|       const snapshot = { ...base } | ||||
|       snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null | ||||
|       base.recur.count = occurrenceIndex | ||||
|       const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ) | ||||
|       if (!nextStartStr) return | ||||
|       const originalNumeric = | ||||
|         snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10) | ||||
|       let remainingCount = 'unlimited' | ||||
|       if (originalNumeric !== Infinity) { | ||||
|         const rem = originalNumeric - (occurrenceIndex + 1) | ||||
|         if (rem <= 0) return | ||||
|         remainingCount = String(rem) | ||||
|       } | ||||
|         if (dayShift !== 0) { | ||||
|           const rotatedWeekdays = [false, false, false, false, false, false, false] | ||||
|           for (let i = 0; i < 7; i++) { | ||||
|             if (base.repeatWeekdays[i]) { | ||||
|               let nd = (i + dayShift) % 7 | ||||
|               if (nd < 0) nd += 7 | ||||
|               rotatedWeekdays[nd] = true | ||||
|       this.createEvent({ | ||||
|         title: snapshot.title, | ||||
|         startDate: nextStartStr, | ||||
|         days: snapshot.days, | ||||
|         colorId: snapshot.colorId, | ||||
|         recur: snapshot.recur | ||||
|           ? { | ||||
|               freq: snapshot.recur.freq, | ||||
|               interval: snapshot.recur.interval, | ||||
|               count: remainingCount, | ||||
|               weekdays: snapshot.recur.weekdays ? [...snapshot.recur.weekdays] : null, | ||||
|             } | ||||
|           } | ||||
|           newRepeatWeekdays = rotatedWeekdays | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       const newId = this.createEvent({ | ||||
|         title: base.title, | ||||
|         startDate, | ||||
|         endDate, | ||||
|         colorId: base.colorId, | ||||
|         repeat: base.repeat, | ||||
|         repeatCount: newRepeatCount, | ||||
|         repeatWeekdays: newRepeatWeekdays, | ||||
|           : null, | ||||
|       }) | ||||
|       return newId | ||||
|       this.notifyEventsChanged() | ||||
|     }, | ||||
|  | ||||
|     _snapshotBaseEvent(eventId) { | ||||
|       // Return a shallow snapshot of any instance for metadata | ||||
|       for (const [, eventList] of this.events) { | ||||
|         const e = eventList.find((x) => x.id === eventId) | ||||
|         if (e) return { ...e } | ||||
|     deleteFromOccurrence(ctx) { | ||||
|       const { baseId, occurrenceIndex } = ctx | ||||
|       const base = this.getEventById(baseId) | ||||
|       if (!base || !base.recur) return | ||||
|       if (occurrenceIndex === 0) { | ||||
|         this.deleteEvent(baseId) | ||||
|         return | ||||
|       } | ||||
|       return null | ||||
|       this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) | ||||
|       this.notifyEventsChanged() | ||||
|     }, | ||||
|  | ||||
|     _removeEventFromAllDatesById(eventId) { | ||||
|       for (const [dateStr, list] of this.events) { | ||||
|         for (let i = list.length - 1; i >= 0; i--) { | ||||
|           if (list[i].id === eventId) { | ||||
|             list.splice(i, 1) | ||||
|           } | ||||
|         } | ||||
|         if (list.length === 0) this.events.delete(dateStr) | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     _addEventToDateRangeWithId(eventId, baseData, startDate, endDate) { | ||||
|       const s = fromLocalString(startDate) | ||||
|       const e = fromLocalString(endDate) | ||||
|       const multi = startDate < endDate | ||||
|       const payload = { | ||||
|         ...baseData, | ||||
|         id: eventId, | ||||
|         startDate, | ||||
|         endDate, | ||||
|         isSpanning: multi, | ||||
|       } | ||||
|       // Normalize single-day time fields | ||||
|       if (!multi) { | ||||
|         if (!payload.startTime) payload.startTime = '09:00' | ||||
|         if (!payload.durationMinutes) payload.durationMinutes = 60 | ||||
|       } else { | ||||
|         payload.startTime = null | ||||
|         payload.durationMinutes = null | ||||
|       } | ||||
|       const cur = new Date(s) | ||||
|       while (cur <= e) { | ||||
|         const dateStr = toLocalString(cur) | ||||
|         if (!this.events.has(dateStr)) this.events.set(dateStr, []) | ||||
|         this.events.get(dateStr).push({ ...payload }) | ||||
|         cur.setDate(cur.getDate() + 1) | ||||
|       } | ||||
|     }, | ||||
|  | ||||
|     // expandRepeats removed: no physical occurrence expansion | ||||
|  | ||||
|     // Adjust start/end range of a base event (non-generated) and reindex occurrences | ||||
|     setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { | ||||
|       const snapshot = this._findEventInAnyList(eventId) | ||||
|     setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto', rotatePattern = true } = {}) { | ||||
|       const snapshot = this.events.get(eventId) | ||||
|       if (!snapshot) return | ||||
|       // Calculate current duration in days (inclusive) | ||||
|       const prevStart = new Date(fromLocalString(snapshot.startDate)) | ||||
|       const prevEnd = new Date(fromLocalString(snapshot.endDate)) | ||||
|       const prevDurationDays = Math.max( | ||||
|         0, | ||||
|         Math.round((prevEnd - prevStart) / (24 * 60 * 60 * 1000)), | ||||
|       ) | ||||
|  | ||||
|       const newStart = new Date(fromLocalString(newStartStr)) | ||||
|       const newEnd = new Date(fromLocalString(newEndStr)) | ||||
|       const proposedDurationDays = Math.max( | ||||
|         0, | ||||
|         Math.round((newEnd - newStart) / (24 * 60 * 60 * 1000)), | ||||
|       ) | ||||
|  | ||||
|       const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ) | ||||
|       const prevDurationDays = (snapshot.days || 1) - 1 | ||||
|       const newStart = fromLocalString(newStartStr, DEFAULT_TZ) | ||||
|       const newEnd = fromLocalString(newEndStr, DEFAULT_TZ) | ||||
|       const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart)) | ||||
|       let finalDurationDays = prevDurationDays | ||||
|       if (mode === 'resize-left' || mode === 'resize-right') { | ||||
|       if (mode === 'resize-left' || mode === 'resize-right') | ||||
|         finalDurationDays = proposedDurationDays | ||||
|       } | ||||
|  | ||||
|       snapshot.startDate = newStartStr | ||||
|       snapshot.endDate = toLocalString( | ||||
|         new Date( | ||||
|           new Date(fromLocalString(newStartStr)).setDate( | ||||
|             new Date(fromLocalString(newStartStr)).getDate() + finalDurationDays, | ||||
|           ), | ||||
|         ), | ||||
|       ) | ||||
|       // Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift | ||||
|       snapshot.days = finalDurationDays + 1 | ||||
|       if ( | ||||
|         mode === 'move' && | ||||
|         snapshot.isRepeating && | ||||
|         snapshot.repeat === 'weeks' && | ||||
|         Array.isArray(snapshot.repeatWeekdays) | ||||
|         rotatePattern && | ||||
|         (mode === 'move' || mode === 'resize-left') && | ||||
|         snapshot.recur && | ||||
|         snapshot.recur.freq === 'weeks' && | ||||
|         Array.isArray(snapshot.recur.weekdays) | ||||
|       ) { | ||||
|         const oldDow = prevStart.getDay() | ||||
|         const newDow = newStart.getDay() | ||||
|         const shift = newDow - oldDow | ||||
|         if (shift !== 0) { | ||||
|           const rotated = [false, false, false, false, false, false, false] | ||||
|           for (let i = 0; i < 7; i++) { | ||||
|             if (snapshot.repeatWeekdays[i]) { | ||||
|               let ni = (i + shift) % 7 | ||||
|               if (ni < 0) ni += 7 | ||||
|               rotated[ni] = true | ||||
|           snapshot.recur.weekdays = this._rotateWeekdayPattern(snapshot.recur.weekdays, shift) | ||||
|         } | ||||
|       } | ||||
|           snapshot.repeatWeekdays = rotated | ||||
|         } | ||||
|       } | ||||
|       // Reindex | ||||
|       this._removeEventFromAllDatesById(eventId) | ||||
|       this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate) | ||||
|       // no expansion | ||||
|       this.events.set(eventId, { ...snapshot, isSpanning: snapshot.days > 1 }) | ||||
|       this.notifyEventsChanged() | ||||
|     }, | ||||
|  | ||||
|     // Split a repeating series at a given occurrence index; returns new series id | ||||
|     splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) { | ||||
|       const base = this._findEventInAnyList(baseId) | ||||
|       if (!base || !base.isRepeating) return null | ||||
|       // Capture original repeatCount BEFORE truncation | ||||
|       const originalCountRaw = base.repeatCount | ||||
|       // Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1) | ||||
|     splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) { | ||||
|       const base = this.events.get(baseId) | ||||
|       if (!base || !base.recur) return | ||||
|       const originalCountRaw = base.recur.count | ||||
|       const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ) | ||||
|       const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) | ||||
|       // If series effectively has <=1 occurrence, treat as simple move (no split) and flatten | ||||
|       let totalOccurrences = Infinity | ||||
|       if (originalCountRaw !== 'unlimited') { | ||||
|         const parsed = parseInt(originalCountRaw, 10) | ||||
|         if (!isNaN(parsed)) totalOccurrences = parsed | ||||
|       } | ||||
|       if (totalOccurrences <= 1) { | ||||
|         // Flatten to non-repeating if not already | ||||
|         if (base.recur) { | ||||
|           base.recur = null | ||||
|           this.events.set(baseId, { ...base }) | ||||
|         } | ||||
|         this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move', rotatePattern: true }) | ||||
|         return baseId | ||||
|       } | ||||
|       if (occurrenceDate <= baseStart) { | ||||
|         this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' }) | ||||
|         return baseId | ||||
|       } | ||||
|       let keptOccurrences = 0 | ||||
|       if (base.recur.freq === 'weeks') { | ||||
|         const interval = base.recur.interval || 1 | ||||
|         const pattern = base.recur.weekdays || [] | ||||
|         if (!pattern.some(Boolean)) return | ||||
|         const WEEK_MS = 7 * 86400000 | ||||
|         const blockStartBase = getMondayOfISOWeek(baseStart) | ||||
|         function isAligned(d) { | ||||
|           const blk = getMondayOfISOWeek(d) | ||||
|           const diff = Math.floor((blk - blockStartBase) / WEEK_MS) | ||||
|           return diff % interval === 0 | ||||
|         } | ||||
|         let cursor = new Date(baseStart) | ||||
|         while (cursor < occurrenceDate) { | ||||
|           if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++ | ||||
|           cursor = addDays(cursor, 1) | ||||
|         } | ||||
|       } else if (base.recur.freq === 'months') { | ||||
|         const diffMonths = | ||||
|           (occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 + | ||||
|           (occurrenceDate.getMonth() - baseStart.getMonth()) | ||||
|         const interval = base.recur.interval || 1 | ||||
|         if (diffMonths <= 0 || diffMonths % interval !== 0) return | ||||
|         keptOccurrences = diffMonths | ||||
|       } else { | ||||
|         return | ||||
|       } | ||||
|       this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences) | ||||
|       // After truncation compute base kept count | ||||
|       const truncated = this.events.get(baseId) | ||||
|       if ( | ||||
|         truncated && | ||||
|         truncated.recur && | ||||
|         truncated.recur.count && | ||||
|         truncated.recur.count !== 'unlimited' | ||||
|       ) { | ||||
|         // keptOccurrences already reflects number before split; adjust not needed further | ||||
|       } | ||||
|       let remainingCount = 'unlimited' | ||||
|       if (originalCountRaw !== 'unlimited') { | ||||
|         const total = parseInt(originalCountRaw, 10) | ||||
|         if (!isNaN(total)) { | ||||
|           const rem = total - keptOccurrences | ||||
|           if (rem <= 0) return | ||||
|           remainingCount = String(rem) | ||||
|         } | ||||
|       } | ||||
|       let weekdays = base.recur.weekdays | ||||
|       if (base.recur.freq === 'weeks' && Array.isArray(base.recur.weekdays)) { | ||||
|         const origWeekday = occurrenceDate.getDay() | ||||
|         const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay() | ||||
|         const shift = newWeekday - origWeekday | ||||
|         if (shift !== 0) { | ||||
|           weekdays = this._rotateWeekdayPattern(base.recur.weekdays, shift) | ||||
|         } | ||||
|       } | ||||
|       const newId = this.createEvent({ | ||||
|         title: base.title, | ||||
|         startDate: newStartStr, | ||||
|         days: base.days, | ||||
|         colorId: base.colorId, | ||||
|         recur: { | ||||
|           freq: base.recur.freq, | ||||
|           interval: base.recur.interval, | ||||
|           count: remainingCount, | ||||
|           weekdays, | ||||
|         }, | ||||
|       }) | ||||
|       // Flatten base if single occurrence now | ||||
|       if (truncated && truncated.recur) { | ||||
|         const baseCountNum = | ||||
|           truncated.recur.count === 'unlimited' ? Infinity : parseInt(truncated.recur.count, 10) | ||||
|         if (baseCountNum <= 1) { | ||||
|           truncated.recur = null | ||||
|           this.events.set(baseId, { ...truncated }) | ||||
|         } | ||||
|       } | ||||
|       // Flatten new if single occurrence only | ||||
|       const newly = this.events.get(newId) | ||||
|       if (newly && newly.recur) { | ||||
|         const newCountNum = | ||||
|           newly.recur.count === 'unlimited' ? Infinity : parseInt(newly.recur.count, 10) | ||||
|         if (newCountNum <= 1) { | ||||
|           newly.recur = null | ||||
|           this.events.set(newId, { ...newly }) | ||||
|         } | ||||
|       } | ||||
|       this.notifyEventsChanged() | ||||
|       return newId | ||||
|     }, | ||||
|  | ||||
|     splitRepeatSeries(baseId, occurrenceIndex, newStartStr, _newEndStr) { | ||||
|       const base = this.events.get(baseId) | ||||
|       if (!base || !base.recur) return null | ||||
|       const originalCountRaw = base.recur.count | ||||
|       this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) | ||||
|       // Compute new series repeatCount (remaining occurrences starting at occurrenceIndex) | ||||
|       let newSeriesCount = 'unlimited' | ||||
|       if (originalCountRaw !== 'unlimited') { | ||||
|         const originalNum = parseInt(originalCountRaw, 10) | ||||
| @@ -440,64 +428,54 @@ export const useCalendarStore = defineStore('calendar', { | ||||
|           newSeriesCount = String(Math.max(1, remaining)) | ||||
|         } | ||||
|       } | ||||
|       const newId = this.createEvent({ | ||||
|       return this.createEvent({ | ||||
|         title: base.title, | ||||
|         startDate: newStartStr, | ||||
|         endDate: newEndStr, | ||||
|         days: base.days, | ||||
|         colorId: base.colorId, | ||||
|         repeat: base.repeat, | ||||
|         repeatInterval: base.repeatInterval, | ||||
|         repeatCount: newSeriesCount, | ||||
|         repeatWeekdays: base.repeatWeekdays, | ||||
|         recur: base.recur | ||||
|           ? { | ||||
|               freq: base.recur.freq, | ||||
|               interval: base.recur.interval, | ||||
|               count: newSeriesCount, | ||||
|               weekdays: base.recur.weekdays ? [...base.recur.weekdays] : null, | ||||
|             } | ||||
|           : null, | ||||
|       }) | ||||
|       return newId | ||||
|     }, | ||||
|  | ||||
|     _reindexBaseEvent(eventId, snapshot, startDate, endDate) { | ||||
|       if (!snapshot) return | ||||
|       this._removeEventFromAllDatesById(eventId) | ||||
|       this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate) | ||||
|     }, | ||||
|  | ||||
|     _terminateRepeatSeriesAtIndex(baseId, index) { | ||||
|       // Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split) | ||||
|       for (const [, list] of this.events) { | ||||
|         for (const ev of list) { | ||||
|           if (ev.id === baseId && ev.isRepeating) { | ||||
|             if (ev.repeatCount === 'unlimited') { | ||||
|               ev.repeatCount = String(index) | ||||
|       const ev = this.events.get(baseId) | ||||
|       if (!ev || !ev.recur) return | ||||
|       if (ev.recur.count === 'unlimited') { | ||||
|         ev.recur.count = String(index) | ||||
|       } else { | ||||
|               const rc = parseInt(ev.repeatCount, 10) | ||||
|               if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index)) | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|         const rc = parseInt(ev.recur.count, 10) | ||||
|         if (!isNaN(rc)) ev.recur.count = String(Math.min(rc, index)) | ||||
|       } | ||||
|       this.notifyEventsChanged() | ||||
|     }, | ||||
|  | ||||
|     _findEventInAnyList(eventId) { | ||||
|       for (const [, eventList] of this.events) { | ||||
|         const found = eventList.find((e) => e.id === eventId) | ||||
|         if (found) return found | ||||
|       } | ||||
|       return null | ||||
|   }, | ||||
|  | ||||
|     _addEventToDateRange(event) { | ||||
|       const startDate = fromLocalString(event.startDate) | ||||
|       const endDate = fromLocalString(event.endDate) | ||||
|       const cur = new Date(startDate) | ||||
|  | ||||
|       while (cur <= endDate) { | ||||
|         const dateStr = toLocalString(cur) | ||||
|         if (!this.events.has(dateStr)) { | ||||
|           this.events.set(dateStr, []) | ||||
|         } | ||||
|         this.events.get(dateStr).push({ ...event, isSpanning: event.startDate < event.endDate }) | ||||
|         cur.setDate(cur.getDate() + 1) | ||||
|       } | ||||
|   persist: { | ||||
|     key: 'calendar-store', | ||||
|     storage: localStorage, | ||||
|     paths: ['today', 'config', 'events'], | ||||
|     serializer: { | ||||
|       serialize(value) { | ||||
|         return JSON.stringify(value, (_k, v) => { | ||||
|           if (v instanceof Map) return { __map: true, data: [...v] } | ||||
|           if (v instanceof Set) return { __set: true, data: [...v] } | ||||
|           return v | ||||
|         }) | ||||
|       }, | ||||
|       deserialize(value) { | ||||
|         const revived = JSON.parse(value, (_k, v) => { | ||||
|           if (v && v.__map) return new Map(v.data) | ||||
|           if (v && v.__set) return new Set(v.data) | ||||
|           return v | ||||
|         }) | ||||
|         return revived | ||||
|       }, | ||||
|     }, | ||||
|  | ||||
|     // NOTE: legacy dynamic getEventById for synthetic occurrences removed. | ||||
|   }, | ||||
| }) | ||||
|   | ||||
| @@ -1,4 +1,14 @@ | ||||
| // date-utils.js — Date handling utilities for the calendar | ||||
| // date-utils.js — Restored & clean utilities (date-fns + timezone aware) | ||||
| import * as dateFns from 'date-fns' | ||||
| import { fromZonedTime, toZonedTime } from 'date-fns-tz' | ||||
|  | ||||
| const DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' | ||||
|  | ||||
| // Re-exported iso helpers (keep the same exported names used elsewhere) | ||||
| const getISOWeek = dateFns.getISOWeek | ||||
| const getISOWeekYear = dateFns.getISOWeekYear | ||||
|  | ||||
| // Constants | ||||
| const monthAbbr = [ | ||||
|   'jan', | ||||
|   'feb', | ||||
| @@ -13,201 +23,342 @@ const monthAbbr = [ | ||||
|   'nov', | ||||
|   'dec', | ||||
| ] | ||||
| const DAY_MS = 86400000 | ||||
| const WEEK_MS = 7 * DAY_MS | ||||
| const MIN_YEAR = 100 // less than 100 is interpreted as 19xx | ||||
| const MAX_YEAR = 9999 | ||||
|  | ||||
| // Core helpers ------------------------------------------------------------ | ||||
| /** | ||||
|  * Get ISO week information for a given date | ||||
|  * @param {Date} date - The date to get week info for | ||||
|  * @returns {Object} Object containing week number and year | ||||
|  * Construct a date at local midnight in the specified IANA timezone. | ||||
|  * Returns a native Date whose wall-clock components in that zone are (Y, M, D 00:00:00). | ||||
|  */ | ||||
| const isoWeekInfo = (date) => { | ||||
|   const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) | ||||
|   const day = d.getUTCDay() || 7 | ||||
|   d.setUTCDate(d.getUTCDate() + 4 - day) | ||||
|   const year = d.getUTCFullYear() | ||||
|   const yearStart = new Date(Date.UTC(year, 0, 1)) | ||||
|   const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1 | ||||
|   return { week: Math.ceil(diffDays / 7), year } | ||||
| function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { | ||||
|   const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String( | ||||
|     day, | ||||
|   ).padStart(2, '0')}` | ||||
|   const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone) | ||||
|   return toZonedTime(utcDate, timeZone) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Convert a Date object to a local date string (YYYY-MM-DD format) | ||||
|  * @param {Date} date - The date to convert (defaults to new Date()) | ||||
|  * @returns {string} Date string in YYYY-MM-DD format | ||||
|  * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate). | ||||
|  */ | ||||
| function toLocalString(date = new Date()) { | ||||
|   const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0') | ||||
|   return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` | ||||
| const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => | ||||
|   makeTZDate(year, monthIndex, day, timeZone) | ||||
|  | ||||
| /** | ||||
|  * Construct a UTC-based date/time (wrapper for Date.UTC for consistency). | ||||
|  */ | ||||
| const UTCDate = (year, monthIndex, day, hour = 0, minute = 0, second = 0, ms = 0) => | ||||
|   new Date(Date.UTC(year, monthIndex, day, hour, minute, second, ms)) | ||||
|  | ||||
| function toLocalString(date = new Date(), timeZone = DEFAULT_TZ) { | ||||
|   return dateFns.format(toZonedTime(date, timeZone), 'yyyy-MM-dd') | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Convert a local date string (YYYY-MM-DD) to a Date object | ||||
|  * @param {string} dateString - Date string in YYYY-MM-DD format | ||||
|  * @returns {Date} Date object | ||||
|  */ | ||||
| function fromLocalString(dateString) { | ||||
|   const [year, month, day] = dateString.split('-').map(Number) | ||||
|   return new Date(year, month - 1, day) | ||||
| function fromLocalString(dateString, timeZone = DEFAULT_TZ) { | ||||
|   if (!dateString) return makeTZDate(1970, 0, 1, timeZone) | ||||
|   const parsed = dateFns.parseISO(dateString) | ||||
|   const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone) | ||||
|   return toZonedTime(utcDate, timeZone) || parsed | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get the index of Monday for a given date (0-6, where Monday = 0) | ||||
|  * @param {Date} d - The date | ||||
|  * @returns {number} Monday index (0-6) | ||||
|  */ | ||||
| const mondayIndex = (d) => (d.getDay() + 6) % 7 | ||||
| function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { | ||||
|   const d = toZonedTime(date, timeZone) | ||||
|   const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0 | ||||
|   return dateFns.addDays(dateFns.startOfDay(d), -dow) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Pad a number with leading zeros to make it 2 digits | ||||
|  * @param {number} n - Number to pad | ||||
|  * @returns {string} Padded string | ||||
|  */ | ||||
| const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 | ||||
|  | ||||
| // Count how many days in [startDate..endDate] match the boolean `pattern` array | ||||
| function countPatternDaysInInterval(startDate, endDate, patternArr) { | ||||
|   const days = dateFns.eachDayOfInterval({ | ||||
|     start: dateFns.startOfDay(startDate), | ||||
|     end: dateFns.startOfDay(endDate), | ||||
|   }) | ||||
|   return days.reduce((c, d) => c + (patternArr[dateFns.getDay(d)] ? 1 : 0), 0) | ||||
| } | ||||
|  | ||||
| // Recurrence: Weekly ------------------------------------------------------ | ||||
| function _getRecur(event) { | ||||
|   return event?.recur ?? null | ||||
| } | ||||
|  | ||||
| function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { | ||||
|   const recur = _getRecur(event) | ||||
|   if (!recur || recur.freq !== 'weeks') return null | ||||
|   const pattern = recur.weekdays || [] | ||||
|   if (!pattern.some(Boolean)) return null | ||||
|  | ||||
|   const target = fromLocalString(dateStr, timeZone) | ||||
|   const baseStart = fromLocalString(event.startDate, timeZone) | ||||
|   if (target < baseStart) return null | ||||
|  | ||||
|   const dow = dateFns.getDay(target) | ||||
|   if (!pattern[dow]) return null // target not active | ||||
|  | ||||
|   const interval = recur.interval || 1 | ||||
|   const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone) | ||||
|   const currentBlockStart = getMondayOfISOWeek(target, timeZone) | ||||
|   // Number of weeks between block starts (each block start is a Monday) | ||||
|   const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart) | ||||
|   if (weekDiff < 0 || weekDiff % interval !== 0) return null | ||||
|  | ||||
|   const baseDow = dateFns.getDay(baseStart) | ||||
|   const baseCountsAsPattern = !!pattern[baseDow] | ||||
|  | ||||
|   // Same ISO week as base: count pattern days from baseStart up to target (inclusive) | ||||
|   if (weekDiff === 0) { | ||||
|     let n = countPatternDaysInInterval(baseStart, target, pattern) - 1 | ||||
|     if (!baseCountsAsPattern) n += 1 | ||||
|     const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) | ||||
|     return n < 0 || n >= maxCount ? null : n | ||||
|   } | ||||
|  | ||||
|   const baseWeekEnd = dateFns.addDays(baseBlockStart, 6) | ||||
|   // Count pattern days in the first (possibly partial) week from baseStart..baseWeekEnd | ||||
|   const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern) | ||||
|   const alignedWeeksBetween = weekDiff / interval - 1 | ||||
|   const fullPatternWeekCount = pattern.filter(Boolean).length | ||||
|   const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0 | ||||
|   // Count pattern days in the current (possibly partial) week from currentBlockStart..target | ||||
|   const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern) | ||||
|   let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1 | ||||
|   if (!baseCountsAsPattern) n += 1 | ||||
|   const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) | ||||
|   return n >= maxCount ? null : n | ||||
| } | ||||
|  | ||||
| // Recurrence: Monthly ----------------------------------------------------- | ||||
| function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { | ||||
|   const recur = _getRecur(event) | ||||
|   if (!recur || recur.freq !== 'months') return null | ||||
|   const baseStart = fromLocalString(event.startDate, timeZone) | ||||
|   const d = fromLocalString(dateStr, timeZone) | ||||
|   const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart) | ||||
|   if (diffMonths < 0) return null | ||||
|   const interval = recur.interval || 1 | ||||
|   if (diffMonths % interval !== 0) return null | ||||
|   const baseDay = dateFns.getDate(baseStart) | ||||
|   const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d)) | ||||
|   if (dateFns.getDate(d) !== effectiveDay) return null | ||||
|   const n = diffMonths / interval | ||||
|   const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) | ||||
|   return n >= maxCount ? null : n | ||||
| } | ||||
|  | ||||
| function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { | ||||
|   const recur = _getRecur(event) | ||||
|   if (!recur) return null | ||||
|   if (dateStr < event.startDate) return null | ||||
|   if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone) | ||||
|   if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone) | ||||
|   return null | ||||
| } | ||||
|  | ||||
| // Reverse lookup: given a recurrence index (0-based) return the occurrence start date string. | ||||
| // Returns null if the index is out of range or the event is not repeating. | ||||
| function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { | ||||
|   const recur = _getRecur(event) | ||||
|   if (!recur || recur.freq !== 'weeks') return null | ||||
|   if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null | ||||
|   const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) | ||||
|   if (occurrenceIndex >= maxCount) return null | ||||
|   const pattern = recur.weekdays || [] | ||||
|   if (!pattern.some(Boolean)) return null | ||||
|   const interval = recur.interval || 1 | ||||
|   const baseStart = fromLocalString(event.startDate, timeZone) | ||||
|   if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone) | ||||
|   const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone) | ||||
|   const baseDow = dateFns.getDay(baseStart) | ||||
|   const baseCountsAsPattern = !!pattern[baseDow] | ||||
|   // Adjust index if base weekday is not part of the pattern (pattern occurrences shift by +1) | ||||
|   let occ = occurrenceIndex | ||||
|   if (!baseCountsAsPattern) occ -= 1 | ||||
|   if (occ < 0) return null | ||||
|   // Sorted list of active weekday indices | ||||
|   const patternDays = [] | ||||
|   for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d) | ||||
|   // First (possibly partial) week: only pattern days >= baseDow and >= baseStart date | ||||
|   const firstWeekDates = [] | ||||
|   for (const d of patternDays) { | ||||
|     if (d < baseDow) continue | ||||
|     const date = dateFns.addDays(baseWeekMonday, d) | ||||
|     if (date < baseStart) continue | ||||
|     firstWeekDates.push(date) | ||||
|   } | ||||
|   const F = firstWeekDates.length | ||||
|   if (occ < F) { | ||||
|     return toLocalString(firstWeekDates[occ], timeZone) | ||||
|   } | ||||
|   const remaining = occ - F | ||||
|   const P = patternDays.length | ||||
|   if (P === 0) return null | ||||
|   // Determine aligned week group (k >= 1) in which the remaining-th occurrence lies | ||||
|   const k = Math.floor(remaining / P) + 1 // 1-based aligned week count after base week | ||||
|   const indexInWeek = remaining % P | ||||
|   const dow = patternDays[indexInWeek] | ||||
|   const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow) | ||||
|   return toLocalString(occurrenceDate, timeZone) | ||||
| } | ||||
|  | ||||
| function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { | ||||
|   const recur = _getRecur(event) | ||||
|   if (!recur || recur.freq !== 'months') return null | ||||
|   if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null | ||||
|   const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) | ||||
|   if (occurrenceIndex >= maxCount) return null | ||||
|   const interval = recur.interval || 1 | ||||
|   const baseStart = fromLocalString(event.startDate, timeZone) | ||||
|   const targetMonthOffset = occurrenceIndex * interval | ||||
|   const monthDate = dateFns.addMonths(baseStart, targetMonthOffset) | ||||
|   // Adjust day for shorter months (clamp like forward logic) | ||||
|   const baseDay = dateFns.getDate(baseStart) | ||||
|   const daysInTargetMonth = dateFns.getDaysInMonth(monthDate) | ||||
|   const day = Math.min(baseDay, daysInTargetMonth) | ||||
|   const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone) | ||||
|   return toLocalString(actual, timeZone) | ||||
| } | ||||
|  | ||||
| function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { | ||||
|   const recur = _getRecur(event) | ||||
|   if (!recur) return null | ||||
|   if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone) | ||||
|   if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone) | ||||
|   return null | ||||
| } | ||||
|  | ||||
| function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) { | ||||
|   const spanDays = Math.max(0, (event.days || 1) - 1) | ||||
|   const occurrenceStart = fromLocalString(occurrenceStartDate, timeZone) | ||||
|   return toLocalString(dateFns.addDays(occurrenceStart, spanDays), timeZone) | ||||
| } | ||||
|  | ||||
| // Utility formatting & localization --------------------------------------- | ||||
| const pad = (n) => String(n).padStart(2, '0') | ||||
|  | ||||
| /** | ||||
|  * Calculate number of days between two date strings (inclusive) | ||||
|  * @param {string} aStr - First date string (YYYY-MM-DD) | ||||
|  * @param {string} bStr - Second date string (YYYY-MM-DD) | ||||
|  * @returns {number} Number of days inclusive | ||||
|  */ | ||||
| function daysInclusive(aStr, bStr) { | ||||
|   const a = fromLocalString(aStr) | ||||
|   const b = fromLocalString(bStr) | ||||
|   const A = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime() | ||||
|   const B = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() | ||||
|   return Math.floor(Math.abs(B - A) / DAY_MS) + 1 | ||||
| function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { | ||||
|   const a = fromLocalString(aStr, timeZone) | ||||
|   const b = fromLocalString(bStr, timeZone) | ||||
|   return ( | ||||
|     Math.abs(dateFns.differenceInCalendarDays(dateFns.startOfDay(a), dateFns.startOfDay(b))) + 1 | ||||
|   ) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Add days to a date string | ||||
|  * @param {string} str - Date string in YYYY-MM-DD format | ||||
|  * @param {number} n - Number of days to add (can be negative) | ||||
|  * @returns {string} New date string | ||||
|  */ | ||||
| function addDaysStr(str, n) { | ||||
|   const d = fromLocalString(str) | ||||
|   d.setDate(d.getDate() + n) | ||||
|   return toLocalString(d) | ||||
| function addDaysStr(str, n, timeZone = DEFAULT_TZ) { | ||||
|   return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get localized weekday names starting from Monday | ||||
|  * @returns {Array<string>} Array of localized weekday names | ||||
|  */ | ||||
| function getLocalizedWeekdayNames() { | ||||
|   const res = [] | ||||
|   const base = new Date(2025, 0, 6) // A Monday | ||||
|   for (let i = 0; i < 7; i++) { | ||||
|     const d = new Date(base) | ||||
|     d.setDate(base.getDate() + i) | ||||
|     res.push(d.toLocaleDateString(undefined, { weekday: 'short' })) | ||||
|   } | ||||
|   return res | ||||
| function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | ||||
|   const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday | ||||
|   return Array.from({ length: 7 }, (_, i) => | ||||
|     new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( | ||||
|       dateFns.addDays(monday, i), | ||||
|     ), | ||||
|   ) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get the locale's first day of the week (0=Sunday, 1=Monday, etc.) | ||||
|  * @returns {number} First day of the week (0-6) | ||||
|  */ | ||||
| function getLocaleFirstDay() { | ||||
|   try { | ||||
|     return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 | ||||
|   } catch { | ||||
|     return 1 // Default to Monday if locale info not available | ||||
|   } | ||||
|   const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1 | ||||
|   return day % 7 | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get the locale's weekend days as an array of booleans (Sunday=index 0) | ||||
|  * @returns {Array<boolean>} Array where true indicates a weekend day | ||||
|  */ | ||||
| function getLocaleWeekendDays() { | ||||
|   try { | ||||
|     const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend | ||||
|     const dayidx = new Set(localeWeekend) | ||||
|     return Array.from({ length: 7 }, (_, i) => dayidx.has(i || 7)) | ||||
|   } catch { | ||||
|     return [true, false, false, false, false, false, true] // Default to Saturday/Sunday weekend | ||||
|   } | ||||
|   const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7]) | ||||
|   return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7))) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Reorder a 7-element array based on the first day of the week | ||||
|  * @param {Array} days - Array of 7 elements (Sunday=index 0) | ||||
|  * @param {number} firstDay - First day of the week (0=Sunday, 1=Monday, etc.) | ||||
|  * @returns {Array} Reordered array | ||||
|  */ | ||||
| function reorderByFirstDay(days, firstDay) { | ||||
|   return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get localized month name | ||||
|  * @param {number} idx - Month index (0-11) | ||||
|  * @param {boolean} short - Whether to return short name | ||||
|  * @returns {string} Localized month name | ||||
|  */ | ||||
| function getLocalizedMonthName(idx, short = false) { | ||||
|   const d = new Date(2025, idx, 1) | ||||
|   return d.toLocaleDateString(undefined, { month: short ? 'short' : 'long' }) | ||||
| function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) { | ||||
|   const d = makeTZDate(2025, idx, 1, timeZone) | ||||
|   return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Format a date range for display | ||||
|  * @param {Date} startDate - Start date | ||||
|  * @param {Date} endDate - End date | ||||
|  * @returns {string} Formatted date range string | ||||
|  */ | ||||
| function formatDateRange(startDate, endDate) { | ||||
|   if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) | ||||
|   const startISO = toLocalString(startDate) | ||||
|   const endISO = toLocalString(endDate) | ||||
|   const [sy, sm] = startISO.split('-') | ||||
|   const [ey, em, ed] = endISO.split('-') | ||||
|   if (sy === ey && sm === em) return `${startISO}/${ed}` | ||||
|   if (sy === ey) return `${startISO}/${em}-${ed}` | ||||
|   return `${startISO}/${endISO}` | ||||
| function formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { | ||||
|   const a = toLocalString(startDate, timeZone) | ||||
|   const b = toLocalString(endDate, timeZone) | ||||
|   if (a === b) return a | ||||
|   const [ay, am] = a.split('-') | ||||
|   const [by, bm, bd] = b.split('-') | ||||
|   if (ay === by && am === bm) return `${a}/${bd}` | ||||
|   if (ay === by) return `${a}/${bm}-${bd}` | ||||
|   return `${a}/${b}` | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Compute lunar phase symbol for the four main phases on a given date. | ||||
|  * Returns one of: 🌑 (new), 🌓 (first quarter), 🌕 (full), 🌗 (last quarter), or '' otherwise. | ||||
|  * Uses an approximate algorithm with a fixed epoch. | ||||
|  */ | ||||
| function lunarPhaseSymbol(date) { | ||||
|   // Reference new moon: 2000-01-06 18:14 UTC (J2000 era), often used in approximations | ||||
|   const ref = Date.UTC(2000, 0, 6, 18, 14, 0) | ||||
|   const synodic = 29.530588853 // days | ||||
|   // Use UTC noon of given date to reduce timezone edge effects | ||||
|   const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0) | ||||
|   const daysSince = (dUTC - ref) / DAY_MS | ||||
|   const phase = (((daysSince / synodic) % 1) + 1) % 1 | ||||
|   // Reference new moon (J2000 era) used for approximate phase calculations | ||||
|   const ref = UTCDate(2000, 0, 6, 18, 14, 0) | ||||
|   const obs = new Date(date) | ||||
|   obs.setHours(12, 0, 0, 0) | ||||
|   const synodic = 29.530588853 // mean synodic month length in days | ||||
|   const daysSince = dateFns.differenceInMinutes(obs, ref) / 60 / 24 | ||||
|   const phase = (((daysSince / synodic) % 1) + 1) % 1 // normalize to [0,1) | ||||
|   const phases = [ | ||||
|     { t: 0.0, s: '🌑' }, // New Moon | ||||
|     { t: 0.0, s: '🌑' }, // New | ||||
|     { t: 0.25, s: '🌓' }, // First Quarter | ||||
|     { t: 0.5, s: '🌕' }, // Full Moon | ||||
|     { t: 0.5, s: '🌕' }, // Full | ||||
|     { t: 0.75, s: '🌗' }, // Last Quarter | ||||
|   ] | ||||
|   // threshold in days from exact phase to still count for this date | ||||
|   const thresholdDays = 0.5 // ±12 hours | ||||
|   const thresholdDays = 0.5 // within ~12h of exact phase | ||||
|   for (const p of phases) { | ||||
|     let delta = Math.abs(phase - p.t) | ||||
|     if (delta > 0.5) delta = 1 - delta | ||||
|     if (delta > 0.5) delta = 1 - delta // wrap shortest arc | ||||
|     if (delta * synodic <= thresholdDays) return p.s | ||||
|   } | ||||
|   return '' | ||||
| } | ||||
|  | ||||
| // Export all functions and constants | ||||
| // Exports ----------------------------------------------------------------- | ||||
| /** | ||||
|  * Format date as short localized string (e.g., "Jan 15") | ||||
|  */ | ||||
| function formatDateShort(date) { | ||||
|   return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }).replace(/, /, ' ') | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Format date as long localized string with optional year (e.g., "Mon Jan 15" or "Mon Jan 15, 2025") | ||||
|  */ | ||||
| function formatDateLong(date, includeYear = false) { | ||||
|   const opts = { | ||||
|     weekday: 'short', | ||||
|     month: 'short', | ||||
|     day: 'numeric', | ||||
|     ...(includeYear ? { year: 'numeric' } : {}), | ||||
|   } | ||||
|   return date.toLocaleDateString(undefined, opts) | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Format date as today string (e.g., "Monday\nJanuary 15") | ||||
|  */ | ||||
| function formatTodayString(date) { | ||||
|   const formatted = date | ||||
|     .toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) | ||||
|     .replace(/,? /, '\n') | ||||
|   return formatted.charAt(0).toUpperCase() + formatted.slice(1) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   // constants | ||||
|   monthAbbr, | ||||
|   DAY_MS, | ||||
|   WEEK_MS, | ||||
|   isoWeekInfo, | ||||
|   MIN_YEAR, | ||||
|   MAX_YEAR, | ||||
|   DEFAULT_TZ, | ||||
|   // core tz helpers | ||||
|   makeTZDate, | ||||
|   toLocalString, | ||||
|   fromLocalString, | ||||
|   // recurrence | ||||
|   getMondayOfISOWeek, | ||||
|   mondayIndex, | ||||
|   getOccurrenceIndex, | ||||
|   getOccurrenceDate, | ||||
|   getVirtualOccurrenceEndDate, | ||||
|   // formatting & localization | ||||
|   pad, | ||||
|   daysInclusive, | ||||
|   addDaysStr, | ||||
| @@ -217,5 +368,14 @@ export { | ||||
|   reorderByFirstDay, | ||||
|   getLocalizedMonthName, | ||||
|   formatDateRange, | ||||
|   formatDateShort, | ||||
|   formatDateLong, | ||||
|   formatTodayString, | ||||
|   lunarPhaseSymbol, | ||||
|   // iso helpers re-export | ||||
|   getISOWeek, | ||||
|   getISOWeekYear, | ||||
|   // constructors | ||||
|   TZDate, | ||||
|   UTCDate, | ||||
| } | ||||
|   | ||||
							
								
								
									
										179
									
								
								src/utils/holidays.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								src/utils/holidays.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,179 @@ | ||||
| // holidays.js — Holiday utilities using date-holidays package | ||||
| import Holidays from 'date-holidays' | ||||
|  | ||||
| let holidaysInstance = null | ||||
| let currentCountry = null | ||||
| let currentState = null | ||||
| let currentRegion = null | ||||
| let holidayCache = new Map() | ||||
| let yearCache = new Map() | ||||
|  | ||||
| /** | ||||
|  * Initialize holidays for a specific country/region | ||||
|  * @param {string} country - Country code (e.g., 'US', 'GB', 'DE') | ||||
|  * @param {string} [state] - State/province code (e.g., 'CA' for California) | ||||
|  * @param {string} [region] - Region code | ||||
|  */ | ||||
| export function initializeHolidays(country, state = null, region = null) { | ||||
|   if (!country) { | ||||
|     console.warn('No country provided for holiday initialization') | ||||
|     holidaysInstance = null | ||||
|     return false | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     holidaysInstance = new Holidays(country, state, region) | ||||
|     currentCountry = country | ||||
|     currentState = state | ||||
|     currentRegion = region | ||||
|  | ||||
|     holidayCache.clear() | ||||
|     yearCache.clear() | ||||
|  | ||||
|     return true | ||||
|   } catch (error) { | ||||
|     console.warn('Failed to initialize holidays for', country, state, region, error) | ||||
|     holidaysInstance = null | ||||
|     return false | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get holidays for a specific year | ||||
|  * @param {number} year - The year to get holidays for | ||||
|  * @returns {Array} Array of holiday objects | ||||
|  */ | ||||
| export function getHolidaysForYear(year) { | ||||
|   if (!holidaysInstance) { | ||||
|     return [] | ||||
|   } | ||||
|  | ||||
|   if (yearCache.has(year)) { | ||||
|     return yearCache.get(year) | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     const holidays = holidaysInstance.getHolidays(year) | ||||
|     yearCache.set(year, holidays) | ||||
|     return holidays | ||||
|   } catch (error) { | ||||
|     console.warn('Failed to get holidays for year', year, error) | ||||
|     return [] | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get holiday for a specific date | ||||
|  * @param {string|Date} date - Date in YYYY-MM-DD format or Date object | ||||
|  * @returns {Object|null} Holiday object or null if no holiday | ||||
|  */ | ||||
| export function getHolidayForDate(date) { | ||||
|   if (!holidaysInstance) { | ||||
|     return null | ||||
|   } | ||||
|  | ||||
|   const cacheKey = typeof date === 'string' ? date : date.toISOString().split('T')[0] | ||||
|   if (holidayCache.has(cacheKey)) { | ||||
|     return holidayCache.get(cacheKey) | ||||
|   } | ||||
|  | ||||
|   try { | ||||
|     let dateObj | ||||
|     if (typeof date === 'string') { | ||||
|       const [year, month, day] = date.split('-').map(Number) | ||||
|       dateObj = new Date(year, month - 1, day) | ||||
|     } else { | ||||
|       dateObj = date | ||||
|     } | ||||
|  | ||||
|     const year = dateObj.getFullYear() | ||||
|     const holidays = getHolidaysForYear(year) | ||||
|  | ||||
|     const holiday = holidays.find((h) => { | ||||
|       const holidayDate = new Date(h.date) | ||||
|       return ( | ||||
|         holidayDate.getFullYear() === dateObj.getFullYear() && | ||||
|         holidayDate.getMonth() === dateObj.getMonth() && | ||||
|         holidayDate.getDate() === dateObj.getDate() | ||||
|       ) | ||||
|     }) | ||||
|  | ||||
|     const result = holiday || null | ||||
|     holidayCache.set(cacheKey, result) | ||||
|  | ||||
|     return result | ||||
|   } catch (error) { | ||||
|     console.warn('Failed to get holiday for date', date, error) | ||||
|     return null | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Check if a date is a holiday | ||||
|  * @param {string|Date} date - Date in YYYY-MM-DD format or Date object | ||||
|  * @returns {boolean} True if the date is a holiday | ||||
|  */ | ||||
| export function isHoliday(date) { | ||||
|   return getHolidayForDate(date) !== null | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get available countries for holidays | ||||
|  * @returns {Array} Array of country codes | ||||
|  */ | ||||
| export function getAvailableCountries() { | ||||
|   try { | ||||
|     const holidays = new Holidays() | ||||
|     const countries = holidays.getCountries() | ||||
|  | ||||
|     // The getCountries method might return an object, convert to array of keys | ||||
|     if (countries && typeof countries === 'object') { | ||||
|       return Array.isArray(countries) ? countries : Object.keys(countries) | ||||
|     } | ||||
|  | ||||
|     return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] // Fallback | ||||
|   } catch (error) { | ||||
|     console.warn('Failed to get available countries', error) | ||||
|     return ['US', 'GB', 'DE', 'FR', 'CA', 'AU'] // Fallback to common countries | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get available states/regions for a country | ||||
|  * @param {string} country - Country code | ||||
|  * @returns {Array} Array of state/region codes | ||||
|  */ | ||||
| export function getAvailableStates(country) { | ||||
|   try { | ||||
|     if (!country) return [] | ||||
|  | ||||
|     const holidays = new Holidays() | ||||
|     const states = holidays.getStates(country) | ||||
|  | ||||
|     // The getStates method might return an object, convert to array of keys | ||||
|     if (states && typeof states === 'object') { | ||||
|       return Array.isArray(states) ? states : Object.keys(states) | ||||
|     } | ||||
|  | ||||
|     return [] | ||||
|   } catch (error) { | ||||
|     console.warn('Failed to get available states for', country, error) | ||||
|     return [] | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Get holiday configuration info | ||||
|  * @returns {Object} Current holiday configuration | ||||
|  */ | ||||
| export function getHolidayConfig() { | ||||
|   return { | ||||
|     country: currentCountry, | ||||
|     state: currentState, | ||||
|     region: currentRegion, | ||||
|     initialized: !!holidaysInstance, | ||||
|   } | ||||
| } | ||||
|  | ||||
| // Initialize with US holidays by default | ||||
| initializeHolidays('US') | ||||
		Reference in New Issue
	
	Block a user