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/" |     "format": "prettier --write src/" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|  |     "date-fns": "^3.6.0", | ||||||
|  |     "date-fns-tz": "^3.0.0", | ||||||
|  |     "date-holidays": "^3.25.1", | ||||||
|     "pinia": "^3.0.3", |     "pinia": "^3.0.3", | ||||||
|  |     "pinia-plugin-persistedstate": "^4.5.0", | ||||||
|     "vue": "^3.5.18" |     "vue": "^3.5.18" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
| @@ -34,4 +38,4 @@ | |||||||
|     "vite": "npm:rolldown-vite@latest", |     "vite": "npm:rolldown-vite@latest", | ||||||
|     "vite-plugin-vue-devtools": "^8.0.0" |     "vite-plugin-vue-devtools": "^8.0.0" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								src/App.vue
									
									
									
									
									
								
							
							
						
						
									
										42
									
								
								src/App.vue
									
									
									
									
									
								
							| @@ -1,9 +1,45 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref } from 'vue' | import { ref, onMounted, onBeforeUnmount } from 'vue' | ||||||
| import CalendarView from './components/CalendarView.vue' | import CalendarView from './components/CalendarView.vue' | ||||||
| import EventDialog from './components/EventDialog.vue' | import EventDialog from './components/EventDialog.vue' | ||||||
|  | import { useCalendarStore } from './stores/CalendarStore' | ||||||
|  |  | ||||||
| const eventDialog = ref(null) | 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) => { | const handleCreateEvent = (eventData) => { | ||||||
|   if (eventDialog.value) { |   if (eventDialog.value) { | ||||||
| @@ -15,9 +51,9 @@ const handleCreateEvent = (eventData) => { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| const handleEditEvent = (eventInstanceId) => { | const handleEditEvent = (eventClickPayload) => { | ||||||
|   if (eventDialog.value) { |   if (eventDialog.value) { | ||||||
|     eventDialog.value.openEditDialog(eventInstanceId) |     eventDialog.value.openEditDialog(eventClickPayload) | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,6 +20,10 @@ | |||||||
|   --label-bg: #fafbfe; |   --label-bg: #fafbfe; | ||||||
|   --label-bg-rgb: 250, 251, 254; |   --label-bg-rgb: 250, 251, 254; | ||||||
|  |  | ||||||
|  |   /* Holiday colors */ | ||||||
|  |   --holiday: #da0; | ||||||
|  |   --holiday-label: var(--strong); | ||||||
|  |  | ||||||
|   /* Input / recurrence tokens */ |   /* Input / recurrence tokens */ | ||||||
|   --input-border: var(--muted-alt); |   --input-border: var(--muted-alt); | ||||||
|   --input-focus: var(--accent); |   --input-focus: var(--accent); | ||||||
| @@ -34,28 +38,68 @@ | |||||||
| } | } | ||||||
|  |  | ||||||
| /* Month tints (light) */ | /* Month tints (light) */ | ||||||
| .dec { background: hsl(220 50% 95%) } | .dec { | ||||||
| .jan { background: hsl(220 50% 92%) } |   background: hsl(220 50% 95%); | ||||||
| .feb { background: hsl(220 50% 95%) } | } | ||||||
| .mar { background: hsl(125 60% 92%) } | .jan { | ||||||
| .apr { background: hsl(125 60% 95%) } |   background: hsl(220 50% 92%); | ||||||
| .may { background: hsl(125 60% 92%) } | } | ||||||
| .jun { background: hsl(45 85% 95%) } | .feb { | ||||||
| .jul { background: hsl(45 85% 92%) } |   background: hsl(220 50% 95%); | ||||||
| .aug { background: hsl(45 85% 95%) } | } | ||||||
| .sep { background: hsl(18 78% 92%) } | .mar { | ||||||
| .oct { background: hsl(18 78% 95%) } |   background: hsl(125 60% 92%); | ||||||
| .nov { background: hsl(18 78% 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 */ | /* Light mode — gray shades and colors */ | ||||||
| .event-color-0 { background: hsl(0, 0%, 85%) }  /* lightest grey */ | .event-color-0 { | ||||||
| .event-color-1 { background: hsl(0, 0%, 75%) }  /* light grey */ |   background: hsl(0, 0%, 85%); | ||||||
| .event-color-2 { background: hsl(0, 0%, 65%) }  /* medium grey */ | } /* lightest grey */ | ||||||
| .event-color-3 { background: hsl(0, 0%, 55%) }  /* dark grey */ | .event-color-1 { | ||||||
| .event-color-4 { background: hsl(0, 70%, 70%) }  /* red */ |   background: hsl(0, 0%, 75%); | ||||||
| .event-color-5 { background: hsl(90, 70%, 70%) }  /* green */ | } /* light grey */ | ||||||
| .event-color-6 { background: hsl(230, 70%, 70%) } /* blue */ | .event-color-2 { | ||||||
| .event-color-7 { background: hsl(280, 70%, 70%) } /* purple */ |   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) */ | /* Color tokens (dark) */ | ||||||
| @media (prefers-color-scheme: dark) { | @media (prefers-color-scheme: dark) { | ||||||
| @@ -69,7 +113,7 @@ | |||||||
|     --muted: #7d8691; |     --muted: #7d8691; | ||||||
|     --muted-alt: #5d646d; |     --muted-alt: #5d646d; | ||||||
|     --accent: #3b82f6; |     --accent: #3b82f6; | ||||||
|     --accent-soft: rgba(59,130,246,0.15); |     --accent-soft: rgba(59, 130, 246, 0.15); | ||||||
|     --accent-hover: #2563eb; |     --accent-hover: #2563eb; | ||||||
|     --danger: #ef4444; |     --danger: #ef4444; | ||||||
|     --danger-hover: #dc2626; |     --danger-hover: #dc2626; | ||||||
| @@ -85,32 +129,76 @@ | |||||||
|     --pill-bg: #222a32; |     --pill-bg: #222a32; | ||||||
|     --pill-active-bg: var(--accent); |     --pill-active-bg: var(--accent); | ||||||
|     --pill-active-ink: #fff; |     --pill-active-ink: #fff; | ||||||
|     --pill-hover-bg: rgba(255,255,255,0.08); |     --pill-hover-bg: rgba(255, 255, 255, 0.08); | ||||||
|  |  | ||||||
|     /* Vue component color mappings (dark) */ |     /* Vue component color mappings (dark) */ | ||||||
|     --bg: var(--panel); |     --bg: var(--panel); | ||||||
|     --border-color: #333; |     --border-color: #333; | ||||||
|  |  | ||||||
|  |     /* Holiday colors (dark mode) */ | ||||||
|  |     --holiday: #ffc107; | ||||||
|  |     --holiday-label: #fff8e1; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   .dec { background: hsl(220 50% 8%) } |   .dec { | ||||||
|   .jan { background: hsl(220 50% 6%) } |     background: hsl(220 50% 8%); | ||||||
|   .feb { background: hsl(220 50% 8%) } |   } | ||||||
|   .mar { background: hsl(125 60% 6%) } |   .jan { | ||||||
|   .apr { background: hsl(125 60% 8%) } |     background: hsl(220 50% 6%); | ||||||
|   .may { background: hsl(125 60% 6%) } |   } | ||||||
|   .jun { background: hsl(45 85% 8%) } |   .feb { | ||||||
|   .jul { background: hsl(45 85% 6%) } |     background: hsl(220 50% 8%); | ||||||
|   .aug { background: hsl(45 85% 8%) } |   } | ||||||
|   .sep { background: hsl(18 78% 6%) } |   .mar { | ||||||
|   .oct { background: hsl(18 78% 8%) } |     background: hsl(125 60% 6%); | ||||||
|   .nov { background: hsl(18 78% 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-0 { | ||||||
|   .event-color-1 { background: hsl(0, 0%, 40%) }  /* light grey */ |     background: hsl(0, 0%, 50%); | ||||||
|   .event-color-2 { background: hsl(0, 0%, 30%) }  /* medium grey */ |   } /* lightest grey */ | ||||||
|   .event-color-3 { background: hsl(0, 0%, 20%) }  /* dark grey */ |   .event-color-1 { | ||||||
|   .event-color-4 { background: hsl(0, 70%, 40%) }  /* red */ |     background: hsl(0, 0%, 40%); | ||||||
|   .event-color-5 { background: hsl(90, 70%, 30%) }  /* green - darker for perceptional purposes */ |   } /* light grey */ | ||||||
|   .event-color-6 { background: hsl(230, 70%, 40%) } /* blue */ |   .event-color-2 { | ||||||
|   .event-color-7 { background: hsl(280, 70%, 40%) } /* purple */ |     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 { | :root { | ||||||
|   /* Layout */ |   --week-w: 3rem; | ||||||
|   --row-h: 2.2em; |   --day-w: 1fr; | ||||||
|   --label-w: minmax(4em, 8%); |   --month-w: 2rem; | ||||||
|   --cell-w: 1fr; |   --row-h: 15vh; | ||||||
|   --cell-h: clamp(4em, 8vh, 8em); | } | ||||||
|   --overlay-w: minmax(3rem, 5%); | * { | ||||||
|  |   box-sizing: border-box; | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Layout & typography */ | html, | ||||||
| * { box-sizing: border-box } | body { | ||||||
|  |   height: 100%; | ||||||
|  | } | ||||||
|  |  | ||||||
| body { | body { | ||||||
|   margin: 0; |   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); |   background: var(--bg); | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|  |   overflow: hidden; | ||||||
| } | } | ||||||
|  |  | ||||||
| header { | header { | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: baseline; |   align-items: baseline; | ||||||
|   justify-content: space-between; |   justify-content: space-between; | ||||||
|   margin-bottom: .75rem; |   margin-bottom: 0.75rem; | ||||||
|   flex-shrink: 0; |   flex-shrink: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| .header-controls { | .today-date { | ||||||
|   display: flex; |   cursor: pointer; | ||||||
|   align-items: center; |  | ||||||
|   gap: .75rem; |  | ||||||
| } | } | ||||||
|  | .today-date::first-line { | ||||||
| .today-date { cursor: pointer } |   color: var(--today); | ||||||
| .today-date::first-line { color: var(--today) } | } | ||||||
| .today-button:hover { opacity: .8 } | .today-button:hover { | ||||||
|  |   opacity: 0.8; | ||||||
| /* Header row */ | } | ||||||
| .calendar-header, #calendar-header { | .calendar-header, | ||||||
|  | #calendar-header { | ||||||
|   display: grid; |   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); | ||||||
|   border-bottom: .2em solid var(--muted); |   border-bottom: 0.2em solid var(--muted); | ||||||
|   align-items: last baseline; |   align-items: last baseline; | ||||||
|   flex-shrink: 0; |   flex-shrink: 0; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Main container */ | /* Main container */ | ||||||
| .calendar-container, #calendar-container { | .calendar-container, | ||||||
|  | #calendar-container { | ||||||
|   flex: 1; |   flex: 1; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   position: relative; |   position: relative; | ||||||
| @@ -56,7 +65,8 @@ header { | |||||||
| } | } | ||||||
|  |  | ||||||
| /* Viewports (support id or class) */ | /* Viewports (support id or class) */ | ||||||
| .calendar-viewport, #calendar-viewport { | .calendar-viewport, | ||||||
|  | #calendar-viewport { | ||||||
|   height: 100%; |   height: 100%; | ||||||
|   overflow-y: auto; |   overflow-y: auto; | ||||||
|   overflow-x: hidden; |   overflow-x: hidden; | ||||||
| @@ -65,37 +75,27 @@ header { | |||||||
|   scrollbar-width: none; |   scrollbar-width: none; | ||||||
| } | } | ||||||
| .calendar-viewport::-webkit-scrollbar, | .calendar-viewport::-webkit-scrollbar, | ||||||
| #calendar-viewport::-webkit-scrollbar { display: none } | #calendar-viewport::-webkit-scrollbar { | ||||||
|  |   display: none; | ||||||
| .jogwheel-viewport, #jogwheel-viewport { | } | ||||||
|   position: absolute; | .calendar-content, | ||||||
|   top: 0; right: 0; bottom: 0; | #calendar-content { | ||||||
|   width: var(--overlay-w); |   position: relative; | ||||||
|   overflow-y: auto; |  | ||||||
|   overflow-x: hidden; |  | ||||||
|   scrollbar-width: none; |  | ||||||
|   z-index: 20; |  | ||||||
|   cursor: ns-resize; |  | ||||||
| } | } | ||||||
| .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: label + 7-day grid + jogwheel column */ | ||||||
| .week-row { | .week-row { | ||||||
|   display: grid; |   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; |   position: relative; | ||||||
|   overflow: visible; |   overflow: visible; | ||||||
|   height: var(--cell-h); |   height: var(--row-h); | ||||||
|   scroll-snap-align: start; |   scroll-snap-align: start; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Label cells */ | /* Label cells */ | ||||||
| .year-label, .week-label { | .year-label, | ||||||
|  | .week-label { | ||||||
|   display: grid; |   display: grid; | ||||||
|   place-items: center; |   place-items: center; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| @@ -105,7 +105,7 @@ header { | |||||||
| } | } | ||||||
|  |  | ||||||
| .week-label { | .week-label { | ||||||
|   height: var(--cell-h); |   height: var(--row-h); | ||||||
| } | } | ||||||
| /* 7-day grid inside each week row */ | /* 7-day grid inside each week row */ | ||||||
| .week-row > .days-grid { | .week-row > .days-grid { | ||||||
| @@ -130,7 +130,8 @@ header { | |||||||
|   z-index: 15; |   z-index: 15; | ||||||
|   overflow: visible; |   overflow: visible; | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: 0; right: 0; |   top: 0; | ||||||
|  |   right: 0; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
| .month-name-label > span { | .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> | <script setup> | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   day: Object, |   day: Object, | ||||||
|  |   dragging: { type: Boolean, default: false }, | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const emit = defineEmits(['event-click']) |  | ||||||
|  |  | ||||||
| const handleEventClick = (eventId) => { |  | ||||||
|   emit('event-click', eventId) |  | ||||||
| } |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div |   <div | ||||||
|     class="cell" |     class="cell" | ||||||
|  |     :style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'" | ||||||
|     :class="[ |     :class="[ | ||||||
|       props.day.monthClass, |       props.day.monthClass, | ||||||
|       { |       { | ||||||
| @@ -20,6 +16,7 @@ const handleEventClick = (eventId) => { | |||||||
|         weekend: props.day.isWeekend, |         weekend: props.day.isWeekend, | ||||||
|         firstday: props.day.isFirstDay, |         firstday: props.day.isFirstDay, | ||||||
|         selected: props.day.isSelected, |         selected: props.day.isSelected, | ||||||
|  |         holiday: props.day.isHoliday, | ||||||
|       }, |       }, | ||||||
|     ]" |     ]" | ||||||
|     :data-date="props.day.date" |     :data-date="props.day.date" | ||||||
| @@ -27,19 +24,10 @@ const handleEventClick = (eventId) => { | |||||||
|     <h1>{{ props.day.displayText }}</h1> |     <h1>{{ props.day.displayText }}</h1> | ||||||
|     <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span> |     <span v-if="props.day.lunarPhase" class="lunar-phase">{{ props.day.lunarPhase }}</span> | ||||||
|  |  | ||||||
|     <!-- Simple event display for now --> |     <div v-if="props.day.holiday" class="holiday-info"> | ||||||
|     <div v-if="props.day.events && props.day.events.length > 0" class="day-events"> |       <span class="holiday-name" :title="props.day.holiday.name"> | ||||||
|       <div |         {{ props.day.holiday.name }} | ||||||
|         v-for="event in props.day.events.slice(0, 3)" |       </span> | ||||||
|         :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> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| @@ -50,7 +38,6 @@ const handleEventClick = (eventId) => { | |||||||
|   border-right: 1px solid var(--border-color); |   border-right: 1px solid var(--border-color); | ||||||
|   border-bottom: 1px solid var(--border-color); |   border-bottom: 1px solid var(--border-color); | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   touch-action: none; |  | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-direction: row; |   flex-direction: row; | ||||||
|   align-items: flex-start; |   align-items: flex-start; | ||||||
| @@ -58,7 +45,7 @@ const handleEventClick = (eventId) => { | |||||||
|   padding: 0.25em; |   padding: 0.25em; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: var(--cell-h); |   height: var(--row-h); | ||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   transition: background-color 0.15s ease; |   transition: background-color 0.15s ease; | ||||||
| } | } | ||||||
| @@ -72,20 +59,6 @@ const handleEventClick = (eventId) => { | |||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   transition: background-color 0.15s ease; |   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 { | .cell.weekend h1 { | ||||||
|   color: var(--weekend); |   color: var(--weekend); | ||||||
| } | } | ||||||
| @@ -93,18 +66,64 @@ const handleEventClick = (eventId) => { | |||||||
|   color: var(--firstday); |   color: var(--firstday); | ||||||
|   text-shadow: 0 0 0.1em var(--strong); |   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 { | .cell.selected { | ||||||
|   filter: hue-rotate(180deg); |   filter: hue-rotate(180deg); | ||||||
| } | } | ||||||
| .cell.selected h1 { | .cell.selected h1 { | ||||||
|   color: var(--strong); |   color: var(--strong); | ||||||
| } | } | ||||||
|  |  | ||||||
| .lunar-phase { | .lunar-phase { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: 0.1em; |   top: 0.5em; | ||||||
|   right: 0.1em; |   right: 0.2em; | ||||||
|   font-size: 0.8em; |   font-size: 0.8em; | ||||||
|   opacity: 0.7; |   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> | </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> | <script setup> | ||||||
| import { computed } from 'vue' | import { computed } from 'vue' | ||||||
| import { useCalendarStore } from '@/stores/CalendarStore' | 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({ | const props = defineProps({ | ||||||
|   scrollTop: { type: Number, default: 0 }, |   scrollTop: { type: Number, default: 0 }, | ||||||
| @@ -11,17 +20,64 @@ const props = defineProps({ | |||||||
|  |  | ||||||
| const calendarStore = useCalendarStore() | 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 topDisplayIndex = Math.floor(props.scrollTop / props.rowHeight) | ||||||
|   const topVW = topDisplayIndex + props.minVirtualWeek |   return 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 |  | ||||||
| }) | }) | ||||||
|  |  | ||||||
|  | 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(() => { | const weekdayNames = computed(() => { | ||||||
|   // Get Monday-first names, then reorder by first day, then add weekend info |   // Reorder names & weekend flags | ||||||
|   const mondayFirstNames = getLocalizedWeekdayNames() |   const mondayFirstNames = getLocalizedWeekdayNames() | ||||||
|   const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)] |   const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)] | ||||||
|   const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day) |   const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day) | ||||||
| @@ -36,7 +92,18 @@ const weekdayNames = computed(() => { | |||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div class="calendar-header"> |   <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 |     <div | ||||||
|       v-for="day in weekdayNames" |       v-for="day in weekdayNames" | ||||||
|       :key="day.name" |       :key="day.name" | ||||||
| @@ -52,7 +119,7 @@ const weekdayNames = computed(() => { | |||||||
| <style scoped> | <style scoped> | ||||||
| .calendar-header { | .calendar-header { | ||||||
|   display: grid; |   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); |   border-bottom: 2px solid var(--muted); | ||||||
|   align-items: last baseline; |   align-items: last baseline; | ||||||
|   flex-shrink: 0; |   flex-shrink: 0; | ||||||
| @@ -65,20 +132,11 @@ const weekdayNames = computed(() => { | |||||||
|   -webkit-touch-callout: none; |   -webkit-touch-callout: none; | ||||||
|   -webkit-tap-highlight-color: transparent; |   -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 { | .dow { | ||||||
|   text-transform: uppercase; |   text-transform: uppercase; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   padding: 0.5rem; |   font-weight: 600; | ||||||
|   font-weight: 500; |   font-size: 1.2em; | ||||||
| } | } | ||||||
| .dow.weekend { | .dow.weekend { | ||||||
|   color: var(--weekend); |   color: var(--weekend); | ||||||
|   | |||||||
| @@ -1,235 +1,172 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onBeforeUnmount, computed } from 'vue' | import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | ||||||
| import { useCalendarStore } from '@/stores/CalendarStore' | import { useCalendarStore } from '@/stores/CalendarStore' | ||||||
| import CalendarHeader from '@/components/CalendarHeader.vue' | import CalendarHeader from '@/components/CalendarHeader.vue' | ||||||
| import CalendarWeek from '@/components/CalendarWeek.vue' | import CalendarWeek from '@/components/CalendarWeek.vue' | ||||||
| import Jogwheel from '@/components/Jogwheel.vue' | import HeaderControls from '@/components/HeaderControls.vue' | ||||||
| import { | import { | ||||||
|   isoWeekInfo, |   createScrollManager, | ||||||
|   getLocalizedMonthName, |   createWeekColumnScrollManager, | ||||||
|   monthAbbr, |   createMonthScrollManager, | ||||||
|   lunarPhaseSymbol, | } from '@/plugins/scrollManager' | ||||||
|   pad, | import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date' | ||||||
|   daysInclusive, | import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date' | ||||||
|   addDaysStr, | import { addDays, differenceInWeeks } from 'date-fns' | ||||||
|   formatDateRange, | import { createVirtualWeekManager } from '@/plugins/virtualWeeks' | ||||||
| } from '@/utils/date' |  | ||||||
| import { toLocalString, fromLocalString } from '@/utils/date' |  | ||||||
|  |  | ||||||
| const calendarStore = useCalendarStore() | const calendarStore = useCalendarStore() | ||||||
| const viewport = ref(null) |  | ||||||
|  |  | ||||||
| const emit = defineEmits(['create-event', 'edit-event']) | const emit = defineEmits(['create-event', 'edit-event']) | ||||||
|  | const viewport = ref(null) | ||||||
| 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 viewportHeight = ref(600) | const viewportHeight = ref(600) | ||||||
| const rowHeight = ref(64) | 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 selection = ref({ startDate: null, dayCount: 0 }) | ||||||
| const isDragging = ref(false) | const isDragging = ref(false) | ||||||
| const dragAnchor = ref(null) | 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 minVirtualWeek = computed(() => { | ||||||
|   const date = new Date(calendarStore.minYear, 0, 1) |   const date = new Date(MIN_YEAR, 0, 1) | ||||||
|   const firstDayOfWeek = new Date(date) |  | ||||||
|   const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 |   const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 | ||||||
|   firstDayOfWeek.setDate(date.getDate() - dayOffset) |   const firstDayOfWeek = addDays(date, -dayOffset) | ||||||
|   return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) |   return differenceInWeeks(firstDayOfWeek, baseDate.value) | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const maxVirtualWeek = computed(() => { | const maxVirtualWeek = computed(() => { | ||||||
|   const date = new Date(calendarStore.maxYear, 11, 31) |   const date = new Date(MAX_YEAR, 11, 31) | ||||||
|   const firstDayOfWeek = new Date(date) |  | ||||||
|   const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 |   const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 | ||||||
|   firstDayOfWeek.setDate(date.getDate() - dayOffset) |   const firstDayOfWeek = addDays(date, -dayOffset) | ||||||
|   return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) |   return differenceInWeeks(firstDayOfWeek, baseDate.value) | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const totalVirtualWeeks = computed(() => { | const totalVirtualWeeks = computed(() => { | ||||||
|   return maxVirtualWeek.value - minVirtualWeek.value + 1 |   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(() => { | const contentHeight = computed(() => { | ||||||
|   return totalVirtualWeeks.value * rowHeight.value |   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() { | 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') |   const el = document.createElement('div') | ||||||
|   el.style.position = 'absolute' |   el.style.position = 'absolute' | ||||||
|   el.style.visibility = 'hidden' |   el.style.visibility = 'hidden' | ||||||
|   el.style.height = 'var(--cell-h)' |   el.style.height = 'var(--row-h)' | ||||||
|   document.body.appendChild(el) |   document.body.appendChild(el) | ||||||
|   const h = el.getBoundingClientRect().height || 64 |   const h = el.getBoundingClientRect().height || 64 | ||||||
|   el.remove() |   el.remove() | ||||||
|   rowHeight.value = Math.round(h) |   rowHeight.value = Math.round(h) | ||||||
|   return rowHeight.value |   return rowHeight.value | ||||||
| } | } | ||||||
|  | function measureFromProbe() { | ||||||
| function getWeekIndex(date) { |   if (!rowProbe.value) return | ||||||
|   const firstDayOfWeek = new Date(date) |   const h = rowProbe.value.getBoundingClientRect().height | ||||||
|   const dayOffset = (date.getDay() - calendarStore.config.first_day + 7) % 7 |   if (!h) return | ||||||
|   firstDayOfWeek.setDate(date.getDate() - dayOffset) |   const newH = Math.round(h) | ||||||
|   return Math.floor((firstDayOfWeek - baseDate) / WEEK_MS) |   if (newH !== rowHeight.value) { | ||||||
| } |     const oldH = rowHeight.value | ||||||
|  |     // Anchor: keep the same top virtual week visible. | ||||||
| function getFirstDayForVirtualWeek(virtualWeek) { |     const topVirtualWeek = Math.floor(scrollTop.value / oldH) + minVirtualWeek.value | ||||||
|   const firstDay = new Date(baseDate) |     rowHeight.value = newH | ||||||
|   firstDay.setDate(firstDay.getDate() + virtualWeek * 7) |     const newScrollTop = (topVirtualWeek - minVirtualWeek.value) * newH | ||||||
|   return firstDay |     setScrollTop(newScrollTop, 'row-height-change') | ||||||
| } |     resetWeeks('row-height-change') | ||||||
|  |  | ||||||
| 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() |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     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) |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   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) |  | ||||||
|  |  | ||||||
|       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 { getWeekIndex, getFirstDayForVirtualWeek, goToToday, handleHeaderYearChange } = vwm | ||||||
|   const top = new Date(calendarStore.now) |  | ||||||
|   top.setDate(top.getDate() - 21) | // createWeek logic moved to virtualWeeks plugin | ||||||
|   const targetWeekIndex = getWeekIndex(top) |  | ||||||
|   scrollTop.value = (targetWeekIndex - minVirtualWeek.value) * rowHeight.value | // goToToday now provided by manager | ||||||
|   if (viewport.value) { |  | ||||||
|     viewport.value.scrollTop = scrollTop.value |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function clearSelection() { | function clearSelection() { | ||||||
|   selection.value = { startDate: null, dayCount: 0 } |   selection.value = { startDate: null, dayCount: 0 } | ||||||
| } | } | ||||||
|  |  | ||||||
| function startDrag(dateStr) { | function startDrag(dateStr) { | ||||||
|  |   dateStr = normalizeDate(dateStr) | ||||||
|   if (calendarStore.config.select_days === 0) return |   if (calendarStore.config.select_days === 0) return | ||||||
|   isDragging.value = true |   isDragging.value = true | ||||||
|   dragAnchor.value = dateStr |   dragAnchor.value = dateStr | ||||||
|   selection.value = { startDate: dateStr, dayCount: 1 } |   selection.value = { startDate: dateStr, dayCount: 1 } | ||||||
|  |   addGlobalTouchListeners() | ||||||
| } | } | ||||||
|  |  | ||||||
| function updateDrag(dateStr) { | function updateDrag(dateStr) { | ||||||
| @@ -245,10 +182,102 @@ function endDrag(dateStr) { | |||||||
|   selection.value = { startDate, dayCount } |   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) { | function calculateSelection(anchorStr, otherStr) { | ||||||
|   const limit = calendarStore.config.select_days |   const limit = calendarStore.config.select_days | ||||||
|   const anchorDate = fromLocalString(anchorStr) |   const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ) | ||||||
|   const otherDate = fromLocalString(otherStr) |   const otherDate = fromLocalString(otherStr, DEFAULT_TZ) | ||||||
|   const forward = otherDate >= anchorDate |   const forward = otherDate >= anchorDate | ||||||
|   const span = daysInclusive(anchorStr, otherStr) |   const span = daysInclusive(anchorStr, otherStr) | ||||||
|  |  | ||||||
| @@ -260,21 +289,18 @@ function calculateSelection(anchorStr, otherStr) { | |||||||
|   if (forward) { |   if (forward) { | ||||||
|     return { startDate: anchorStr, dayCount: limit } |     return { startDate: anchorStr, dayCount: limit } | ||||||
|   } else { |   } else { | ||||||
|     const startDate = addDaysStr(anchorStr, -(limit - 1)) |     const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ) | ||||||
|     return { startDate, dayCount: limit } |     return { startDate, dayCount: limit } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| const onScroll = () => { | // ---------------- Week label column drag scrolling ---------------- | ||||||
|   if (viewport.value) { | function getWeekLabelRect() { | ||||||
|     scrollTop.value = viewport.value.scrollTop |   // 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') | ||||||
| const handleJogwheelScrollTo = (newScrollTop) => { |   return weekLabel ? weekLabel.getBoundingClientRect() : null | ||||||
|   if (viewport.value) { |  | ||||||
|     viewport.value.scrollTop = newScrollTop |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
| @@ -283,14 +309,27 @@ onMounted(() => { | |||||||
|  |  | ||||||
|   if (viewport.value) { |   if (viewport.value) { | ||||||
|     viewportHeight.value = viewport.value.clientHeight |     viewportHeight.value = viewport.value.clientHeight | ||||||
|     viewport.value.scrollTop = initialScrollTop.value |     setScrollTop(initialScrollTop.value, 'initial-mount') | ||||||
|     viewport.value.addEventListener('scroll', onScroll) |     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(() => { |   const timer = setInterval(() => { | ||||||
|     calendarStore.updateCurrentDate() |     calendarStore.updateCurrentDate() | ||||||
|   }, 60000) |   }, 60000) | ||||||
|  |  | ||||||
|  |   // Initial incremental build (no existing weeks yet) | ||||||
|  |   scheduleWindowUpdate('init') | ||||||
|  |  | ||||||
|  |   if (window.ResizeObserver && rowProbe.value) { | ||||||
|  |     rowProbeObserver = new ResizeObserver(() => { | ||||||
|  |       measureFromProbe() | ||||||
|  |     }) | ||||||
|  |     rowProbeObserver.observe(rowProbe.value) | ||||||
|  |   } | ||||||
|  |  | ||||||
|   onBeforeUnmount(() => { |   onBeforeUnmount(() => { | ||||||
|     clearInterval(timer) |     clearInterval(timer) | ||||||
|   }) |   }) | ||||||
| @@ -299,113 +338,157 @@ onMounted(() => { | |||||||
| onBeforeUnmount(() => { | onBeforeUnmount(() => { | ||||||
|   if (viewport.value) { |   if (viewport.value) { | ||||||
|     viewport.value.removeEventListener('scroll', onScroll) |     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) => { | const handleDayMouseDown = (d) => { | ||||||
|   startDrag(dateStr) |   d = normalizeDate(d) | ||||||
|  |   if (Date.now() < suppressMouseUntil.value) return | ||||||
|  |   if (registerTap(d, 'mouse')) startDrag(d) | ||||||
| } | } | ||||||
|  | const handleDayMouseEnter = (d) => updateDrag(normalizeDate(d)) | ||||||
| const handleDayMouseEnter = (dateStr) => { | const handleDayMouseUp = (d) => { | ||||||
|   if (isDragging.value) { |   d = normalizeDate(d) | ||||||
|     updateDrag(dateStr) |   if (Date.now() < suppressMouseUntil.value && !isDragging.value) return | ||||||
|  |   if (!isDragging.value) return | ||||||
|  |   endDrag(d) | ||||||
|  |   const ev = createEventFromSelection() | ||||||
|  |   if (ev) { | ||||||
|  |     clearSelection() | ||||||
|  |     emit('create-event', ev) | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | const handleDayTouchStart = (d) => { | ||||||
| const handleDayMouseUp = (dateStr) => { |   d = normalizeDate(d) | ||||||
|   if (isDragging.value) { |   suppressMouseUntil.value = Date.now() + 800 | ||||||
|     endDrag(dateStr) |   if (registerTap(d, 'touch')) startDrag(d) | ||||||
|     const eventData = createEventFromSelection() |  | ||||||
|     if (eventData) { |  | ||||||
|       clearSelection() |  | ||||||
|       emit('create-event', eventData) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| const handleDayTouchStart = (dateStr) => { | const handleEventClick = (payload) => { | ||||||
|   startDrag(dateStr) |   emit('edit-event', payload) | ||||||
| } | } | ||||||
|  |  | ||||||
| const handleDayTouchMove = (dateStr) => { | // header year change delegated to manager | ||||||
|   if (isDragging.value) { |  | ||||||
|     updateDrag(dateStr) | // 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) => { | // Watch first day changes (e.g., first_day config update) to adjust scroll | ||||||
|   if (isDragging.value) { | // Keep roughly same visible date when first_day setting changes. | ||||||
|     endDrag(dateStr) | watch( | ||||||
|     const eventData = createEventFromSelection() |   () => calendarStore.config.first_day, | ||||||
|     if (eventData) { |   () => { | ||||||
|       clearSelection() |     const currentTopVW = Math.floor(scrollTop.value / rowHeight.value) + minVirtualWeek.value | ||||||
|       emit('create-event', eventData) |     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) => { | // Watch lightweight mutation counter only (not deep events map) and rebuild lazily | ||||||
|   emit('edit-event', eventInstanceId) | 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> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div class="wrap"> |   <div class="calendar-view-root"> | ||||||
|     <header> |     <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> | ||||||
|       <h1>Calendar</h1> |     <div class="wrap"> | ||||||
|       <div class="header-controls"> |       <HeaderControls @go-to-today="goToToday" /> | ||||||
|         <div class="today-date" @click="goToToday">{{ todayString }}</div> |       <CalendarHeader | ||||||
|       </div> |         :scroll-top="scrollTop" | ||||||
|     </header> |         :row-height="rowHeight" | ||||||
|     <CalendarHeader |         :min-virtual-week="minVirtualWeek" | ||||||
|       :scroll-top="scrollTop" |         @year-change="handleHeaderYearChange" | ||||||
|       :row-height="rowHeight" |       /> | ||||||
|       :min-virtual-week="minVirtualWeek" |       <div class="calendar-container"> | ||||||
|     /> |         <div class="calendar-viewport" ref="viewport"> | ||||||
|     <div class="calendar-container"> |           <!-- Main calendar content (weeks and days) --> | ||||||
|       <div class="calendar-viewport" ref="viewport"> |           <div class="main-calendar-area"> | ||||||
|         <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> |             <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||||
|           <CalendarWeek |               <CalendarWeek | ||||||
|             v-for="week in visibleWeeks" |                 v-for="week in visibleWeeks" | ||||||
|             :key="week.virtualWeek" |                 :key="week.virtualWeek" | ||||||
|             :week="week" |                 :week="week" | ||||||
|             :style="{ top: week.top + 'px' }" |                 :dragging="isDragging" | ||||||
|             @day-mousedown="handleDayMouseDown" |                 :style="{ top: week.top + 'px' }" | ||||||
|             @day-mouseenter="handleDayMouseEnter" |                 @day-mousedown="handleDayMouseDown" | ||||||
|             @day-mouseup="handleDayMouseUp" |                 @day-mouseenter="handleDayMouseEnter" | ||||||
|             @day-touchstart="handleDayTouchStart" |                 @day-mouseup="handleDayMouseUp" | ||||||
|             @day-touchmove="handleDayTouchMove" |                 @day-touchstart="handleDayTouchStart" | ||||||
|             @day-touchend="handleDayTouchEnd" |                 @event-click="handleEventClick" | ||||||
|             @event-click="handleEventClick" |               /> | ||||||
|           /> |             </div> | ||||||
|           <!-- Month labels positioned absolutely --> |           </div> | ||||||
|           <div |           <!-- Month column area --> | ||||||
|             v-for="week in visibleWeeks" |           <div class="month-column-area"> | ||||||
|             :key="`month-${week.virtualWeek}`" |             <!-- Month labels --> | ||||||
|             v-show="week.monthLabel" |             <div class="month-labels-container" :style="{ height: contentHeight + 'px' }"> | ||||||
|             class="month-name-label" |               <template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> | ||||||
|             :style="{ |                 <div | ||||||
|               top: week.top + 'px', |                   v-if="monthWeek && monthWeek.monthLabel" | ||||||
|               height: week.monthLabel?.height + 'px', |                   class="month-label" | ||||||
|             }" |                   :class="monthWeek.monthLabel?.monthClass" | ||||||
|           > |                   :style="{ | ||||||
|             <span>{{ week.monthLabel?.text }}</span> |                     height: `calc(var(--row-h) * ${monthWeek.monthLabel?.weeksSpan || 1})`, | ||||||
|  |                     top: (monthWeek.top || 0) + 'px', | ||||||
|  |                   }" | ||||||
|  |                   @pointerdown="handleMonthScrollPointerDown" | ||||||
|  |                   @touchstart.prevent="handleMonthScrollTouchStart" | ||||||
|  |                   @wheel="handleMonthScrollWheel" | ||||||
|  |                 > | ||||||
|  |                   <span :class="{ bottomup: shouldRotateMonth(monthWeek.monthLabel?.text) }">{{ | ||||||
|  |                     monthWeek.monthLabel?.text || '' | ||||||
|  |                   }}</span> | ||||||
|  |                 </div> | ||||||
|  |               </template> | ||||||
|  |             </div> | ||||||
|           </div> |           </div> | ||||||
|         </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> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
|  | .calendar-view-root { | ||||||
|  |   display: contents; | ||||||
|  | } | ||||||
| .wrap { | .wrap { | ||||||
|   height: 100vh; |   height: 100vh; | ||||||
|   display: flex; |   display: flex; | ||||||
| @@ -414,33 +497,15 @@ const handleEventClick = (eventInstanceId) => { | |||||||
|  |  | ||||||
| header { | header { | ||||||
|   display: flex; |   display: flex; | ||||||
|   justify-content: space-between; |  | ||||||
|   align-items: center; |   align-items: center; | ||||||
|  |   gap: 1.25rem; | ||||||
|  |   padding: 0.75rem 0.5rem 0.25rem 0.5rem; | ||||||
| } | } | ||||||
|  |  | ||||||
| header h1 { | header h1 { | ||||||
|   margin: 0; |   margin: 0; | ||||||
|   padding: 0; |   padding: 0; | ||||||
| } |   font-size: 1.6rem; | ||||||
| .header-controls { |   font-weight: 600; | ||||||
|   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); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .calendar-container { | .calendar-container { | ||||||
| @@ -460,7 +525,13 @@ header h1 { | |||||||
|   flex: 1; |   flex: 1; | ||||||
|   overflow-y: auto; |   overflow-y: auto; | ||||||
|   overflow-x: hidden; |   overflow-x: hidden; | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: 1fr var(--month-w); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .main-calendar-area { | ||||||
|   position: relative; |   position: relative; | ||||||
|  |   overflow: hidden; | ||||||
| } | } | ||||||
|  |  | ||||||
| .calendar-content { | .calendar-content { | ||||||
| @@ -468,27 +539,52 @@ header h1 { | |||||||
|   width: 100%; |   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; |   position: absolute; | ||||||
|   right: 0; |   left: 0; | ||||||
|   width: 3rem; /* Match jogwheel width */ |   width: 100%; | ||||||
|  |   background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2)); | ||||||
|   font-size: 2em; |   font-size: 2em; | ||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
|   display: flex; |   display: flex; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
|   pointer-events: none; |  | ||||||
|   z-index: 15; |   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; |   display: inline-block; | ||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
|   writing-mode: vertical-rl; |   writing-mode: vertical-rl; | ||||||
|   text-orientation: mixed; |   text-orientation: mixed; | ||||||
|   transform: rotate(180deg); |  | ||||||
|   transform-origin: center; |   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> | </style> | ||||||
|   | |||||||
| @@ -2,11 +2,15 @@ | |||||||
| import CalendarDay from './CalendarDay.vue' | import CalendarDay from './CalendarDay.vue' | ||||||
| import EventOverlay from './EventOverlay.vue' | import EventOverlay from './EventOverlay.vue' | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ week: Object, dragging: { type: Boolean, default: false } }) | ||||||
|   week: Object |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| 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) => { | const handleDayMouseDown = (dateStr) => { | ||||||
|   emit('day-mousedown', dateStr) |   emit('day-mousedown', dateStr) | ||||||
| @@ -24,42 +28,38 @@ const handleDayTouchStart = (dateStr) => { | |||||||
|   emit('day-touchstart', dateStr) |   emit('day-touchstart', dateStr) | ||||||
| } | } | ||||||
|  |  | ||||||
| const handleDayTouchMove = (dateStr) => { | // touchmove & touchend handled globally in CalendarView | ||||||
|   emit('day-touchmove', dateStr) |  | ||||||
|  | const handleEventClick = (payload) => { | ||||||
|  |   emit('event-click', payload) | ||||||
| } | } | ||||||
|  |  | ||||||
| const handleDayTouchEnd = (dateStr) => { | // Only apply upside-down rotation (bottomup) for Latin script month labels | ||||||
|   emit('day-touchend', dateStr) | function shouldRotateMonth(label) { | ||||||
| } |   if (!label) return false | ||||||
|  |   try { | ||||||
| const handleEventClick = (eventId) => { |     return /\p{Script=Latin}/u.test(label) | ||||||
|   emit('event-click', eventId) |   } catch (e) { | ||||||
|  |     return /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/u.test(label) | ||||||
|  |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div  |   <div class="week-row" :style="{ top: `${props.week.top}px` }"> | ||||||
|     class="week-row"  |  | ||||||
|     :style="{ top: `${props.week.top}px` }" |  | ||||||
|   > |  | ||||||
|     <div class="week-label">W{{ props.week.weekNumber }}</div> |     <div class="week-label">W{{ props.week.weekNumber }}</div> | ||||||
|     <div class="days-grid"> |     <div class="days-grid"> | ||||||
|       <CalendarDay  |       <CalendarDay | ||||||
|         v-for="day in props.week.days"  |         v-for="day in props.week.days" | ||||||
|         :key="day.date"  |         :key="day.date" | ||||||
|         :day="day" |         :day="day" | ||||||
|  |         :dragging="props.dragging" | ||||||
|         @mousedown="handleDayMouseDown(day.date)" |         @mousedown="handleDayMouseDown(day.date)" | ||||||
|         @mouseenter="handleDayMouseEnter(day.date)" |         @mouseenter="handleDayMouseEnter(day.date)" | ||||||
|         @mouseup="handleDayMouseUp(day.date)" |         @mouseup="handleDayMouseUp(day.date)" | ||||||
|         @touchstart="handleDayTouchStart(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> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| @@ -67,9 +67,9 @@ const handleEventClick = (eventId) => { | |||||||
| <style scoped> | <style scoped> | ||||||
| .week-row { | .week-row { | ||||||
|   display: grid; |   display: grid; | ||||||
|   grid-template-columns: var(--label-w) repeat(7, 1fr) 3rem; |   grid-template-columns: var(--week-w) repeat(7, 1fr); | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   height: var(--cell-h); |   height: var(--row-h); | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -80,13 +80,8 @@ const handleEventClick = (eventId) => { | |||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
|   font-size: 1.2em; |   font-size: 1.2em; | ||||||
|   font-weight: 500; |   font-weight: 500; | ||||||
|   /* Prevent text selection */ |  | ||||||
|   -webkit-user-select: none; |  | ||||||
|   -moz-user-select: none; |  | ||||||
|   -ms-user-select: none; |  | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   -webkit-touch-callout: none; |   height: var(--row-h); | ||||||
|   -webkit-tap-highlight-color: transparent; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .days-grid { | .days-grid { | ||||||
| @@ -96,10 +91,4 @@ const handleEventClick = (eventId) => { | |||||||
|   height: 100%; |   height: 100%; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Fixed heights for cells and labels (from cells.css) */ |  | ||||||
| .week-row :deep(.cell),  |  | ||||||
| .week-label {  |  | ||||||
|   height: var(--cell-h);  |  | ||||||
| } |  | ||||||
| </style> | </style> | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -5,6 +5,8 @@ | |||||||
|       :key="span.id" |       :key="span.id" | ||||||
|       class="event-span" |       class="event-span" | ||||||
|       :class="[`event-color-${span.colorId}`]" |       :class="[`event-color-${span.colorId}`]" | ||||||
|  |       :data-id="span.id" | ||||||
|  |       :data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0" | ||||||
|       :style="{ |       :style="{ | ||||||
|         gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, |         gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, | ||||||
|         gridRow: `${span.row}`, |         gridRow: `${span.row}`, | ||||||
| @@ -24,174 +26,104 @@ | |||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { computed, ref } from 'vue' | import { computed, ref } from 'vue' | ||||||
| import { useCalendarStore } from '@/stores/CalendarStore' | import { useCalendarStore } from '@/stores/CalendarStore' | ||||||
| import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date' | import { daysInclusive, addDaysStr } from '@/utils/date' | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   week: { |   week: { type: Object, required: true }, | ||||||
|     type: Object, |  | ||||||
|     required: true, |  | ||||||
|   }, |  | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const emit = defineEmits(['event-click']) | const emit = defineEmits(['event-click']) | ||||||
| const store = useCalendarStore() | const store = useCalendarStore() | ||||||
|  |  | ||||||
| // Local drag state | // Drag state | ||||||
| const dragState = ref(null) | const dragState = ref(null) | ||||||
| const justDragged = ref(false) | const justDragged = ref(false) | ||||||
|  |  | ||||||
| // Generate repeat occurrences for a specific date | // Consolidate already-provided day.events into contiguous spans (no recurrence generation) | ||||||
| function generateRepeatOccurrencesForDate(targetDateStr) { | const eventSpans = computed(() => { | ||||||
|   const occurrences = [] |   const weekEvents = new Map() | ||||||
|  |   props.week.days.forEach((day, dayIndex) => { | ||||||
|   // Get all events from the store and check for repeating ones |     day.events.forEach((ev) => { | ||||||
|   for (const [, eventList] of store.events) { |       const key = ev.id | ||||||
|     for (const baseEvent of eventList) { |       if (!weekEvents.has(key)) { | ||||||
|       if (!baseEvent.isRepeating || baseEvent.repeat === 'none') { |         weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex }) | ||||||
|         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 |  | ||||||
|       } else { |       } else { | ||||||
|         // Handle other repeat types (months) |         const ref = weekEvents.get(key) | ||||||
|         let intervalsPassed = 0 |         ref.endIdx = Math.max(ref.endIdx, dayIndex) | ||||||
|         const timeDiff = targetDate - baseStartDate |  | ||||||
|         if (baseEvent.repeat === 'months') { |  | ||||||
|           intervalsPassed = |  | ||||||
|             (targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 + |  | ||||||
|             (targetDate.getMonth() - baseStartDate.getMonth()) |  | ||||||
|         } else { |  | ||||||
|           continue |  | ||||||
|         } |  | ||||||
|         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) { | function handleEventClick(span) { | ||||||
|   if (justDragged.value) return |   if (justDragged.value) return | ||||||
|   // Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context |   // Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n) | ||||||
|   emit('event-click', span.id) |   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) { | function handleEventPointerDown(span, event) { | ||||||
|   // Don't start drag if clicking on resize handle |  | ||||||
|   if (event.target.classList.contains('resize-handle')) return |   if (event.target.classList.contains('resize-handle')) return | ||||||
|  |  | ||||||
|   event.stopPropagation() |   event.stopPropagation() | ||||||
|   // Do not preventDefault here to allow click unless drag threshold is passed |   const idStr = span.id | ||||||
|  |   const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_') | ||||||
|   // Get the date under the pointer |   const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr | ||||||
|   const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget) |   const isVirtual = hasVirtualMarker | ||||||
|   const anchorDate = hit ? hit.date : span.startDate |   // 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( |   startLocalDrag( | ||||||
|     { |     { | ||||||
|       id: span.id, |       id: baseId, | ||||||
|  |       originalId: span.id, | ||||||
|  |       isVirtual, | ||||||
|       mode: 'move', |       mode: 'move', | ||||||
|       pointerStartX: event.clientX, |       pointerStartX: event.clientX, | ||||||
|       pointerStartY: event.clientY, |       pointerStartY: event.clientY, | ||||||
| @@ -203,13 +135,17 @@ function handleEventPointerDown(span, event) { | |||||||
|   ) |   ) | ||||||
| } | } | ||||||
|  |  | ||||||
| // Handle resize handle pointer down |  | ||||||
| function handleResizePointerDown(span, mode, event) { | function handleResizePointerDown(span, mode, event) { | ||||||
|   event.stopPropagation() |   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( |   startLocalDrag( | ||||||
|     { |     { | ||||||
|       id: span.id, |       id: baseId, | ||||||
|  |       originalId: span.id, | ||||||
|  |       isVirtual, | ||||||
|       mode, |       mode, | ||||||
|       pointerStartX: event.clientX, |       pointerStartX: event.clientX, | ||||||
|       pointerStartY: event.clientY, |       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 | // Local drag handling | ||||||
| function startLocalDrag(init, evt) { | function startLocalDrag(init, evt) { | ||||||
|   const spanDays = daysInclusive(init.startDate, init.endDate) |   const spanDays = daysInclusive(init.startDate, init.endDate) | ||||||
| @@ -319,13 +167,39 @@ function startLocalDrag(init, evt) { | |||||||
|     else anchorOffset = daysInclusive(init.startDate, init.anchorDate) - 1 |     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 = { |   dragState.value = { | ||||||
|     ...init, |     ...init, | ||||||
|     anchorOffset, |     anchorOffset, | ||||||
|     originSpanDays: spanDays, |     originSpanDays: spanDays, | ||||||
|     eventMoved: false, |     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 |   // Capture pointer events globally | ||||||
|   if (evt.currentTarget && evt.pointerId !== undefined) { |   if (evt.currentTarget && evt.pointerId !== undefined) { | ||||||
|     try { |     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. | ||||||
|   evt.preventDefault() |   if (!(evt.pointerType === 'touch')) { | ||||||
|  |     evt.preventDefault() | ||||||
|  |   } | ||||||
|  |  | ||||||
|   window.addEventListener('pointermove', onDragPointerMove, { passive: false }) |   window.addEventListener('pointermove', onDragPointerMove, { passive: false }) | ||||||
|   window.addEventListener('pointerup', onDragPointerUp, { passive: false }) |   window.addEventListener('pointerup', onDragPointerUp, { passive: false }) | ||||||
|   window.addEventListener('pointercancel', 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) { | function onDragPointerMove(e) { | ||||||
|   const st = dragState.value |   const st = dragState.value | ||||||
|   if (!st) return |   if (!st) return | ||||||
| @@ -360,7 +255,66 @@ function onDragPointerMove(e) { | |||||||
|  |  | ||||||
|   const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) |   const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) | ||||||
|   if (!ns || !ne) return |   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) { | function onDragPointerUp(e) { | ||||||
| @@ -377,6 +331,8 @@ function onDragPointerUp(e) { | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   const moved = !!st.eventMoved |   const moved = !!st.eventMoved | ||||||
|  |   const finalStart = st.tentativeStart | ||||||
|  |   const finalEnd = st.tentativeEnd | ||||||
|   dragState.value = null |   dragState.value = null | ||||||
|  |  | ||||||
|   window.removeEventListener('pointermove', onDragPointerMove) |   window.removeEventListener('pointermove', onDragPointerMove) | ||||||
| @@ -384,11 +340,27 @@ function onDragPointerUp(e) { | |||||||
|   window.removeEventListener('pointercancel', onDragPointerUp) |   window.removeEventListener('pointercancel', onDragPointerUp) | ||||||
|  |  | ||||||
|   if (moved) { |   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 |     justDragged.value = true | ||||||
|     setTimeout(() => { |     setTimeout(() => { | ||||||
|       justDragged.value = false |       justDragged.value = false | ||||||
|     }, 120) |     }, 120) | ||||||
|   } |   } | ||||||
|  |   // End compound session (snapshot if changed) | ||||||
|  |   store.$history?.endCompound() | ||||||
| } | } | ||||||
|  |  | ||||||
| function computeTentativeRangeFromPointer(st, dropDateStr) { | function computeTentativeRangeFromPointer(st, dropDateStr) { | ||||||
| @@ -416,133 +388,13 @@ function normalizeDateOrder(aStr, bStr) { | |||||||
| } | } | ||||||
|  |  | ||||||
| function applyRangeDuringDrag(st, startDate, endDate) { | function applyRangeDuringDrag(st, startDate, endDate) { | ||||||
|   let ev = store.getEventById(st.id) |   if (st.isVirtual) { | ||||||
|   let isRepeatOccurrence = false |     if (st.mode !== 'move') return // no resize for virtual occurrence | ||||||
|   let baseId = st.id |     // Split-move: occurrence being dragged treated as first of new series | ||||||
|   let repeatIndex = 0 |     store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate) | ||||||
|   let grabbedWeekday = null |     return | ||||||
|  |  | ||||||
|   // 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 |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|  |   store.setEventRange(st.id, startDate, endDate, { mode: st.mode }) | ||||||
|   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 |  | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -564,7 +416,7 @@ function timeToMinutes(timeStr) { | |||||||
|  |  | ||||||
| .event-span { | .event-span { | ||||||
|   padding: 0.1em 0.3em; |   padding: 0.1em 0.3em; | ||||||
|   border-radius: 0.2em; |   border-radius: 1em; | ||||||
|   font-size: clamp(0.45em, 1.8vh, 0.75em); |   font-size: clamp(0.45em, 1.8vh, 0.75em); | ||||||
|   font-weight: 600; |   font-weight: 600; | ||||||
|   cursor: grab; |   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> | <template> | ||||||
|   <div class="jogwheel-viewport" ref="jogwheelViewport" @scroll="handleJogwheelScroll"> |   <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> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { ref, computed, watch } from 'vue' | import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   totalVirtualWeeks: { type: Number, required: true }, |   totalVirtualWeeks: { type: Number, required: true }, | ||||||
|   rowHeight: { type: Number, required: true }, |   rowHeight: { type: Number, required: true }, | ||||||
|   viewportHeight: { type: Number, required: true }, |   viewportHeight: { type: Number, required: true }, | ||||||
|   scrollTop: { type: Number, required: true } |   scrollTop: { type: Number, required: true }, | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const emit = defineEmits(['scroll-to']) | const emit = defineEmits(['scroll-to']) | ||||||
| @@ -19,6 +23,12 @@ const emit = defineEmits(['scroll-to']) | |||||||
| const jogwheelViewport = ref(null) | const jogwheelViewport = ref(null) | ||||||
| const jogwheelContent = ref(null) | const jogwheelContent = ref(null) | ||||||
| const syncLock = 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 | // Jogwheel content height is 1/10th of main calendar | ||||||
| const jogwheelHeight = computed(() => { | const jogwheelHeight = computed(() => { | ||||||
| @@ -30,21 +40,100 @@ const handleJogwheelScroll = () => { | |||||||
|   syncFromJogwheel() |   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 = () => { | const syncFromJogwheel = () => { | ||||||
|   if (!jogwheelViewport.value || !jogwheelContent.value) return |   if (!jogwheelViewport.value || !jogwheelContent.value) return | ||||||
|    |  | ||||||
|   syncLock.value = 'main' |   syncLock.value = 'main' | ||||||
|    |  | ||||||
|   const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight) |   const jogScrollable = Math.max( | ||||||
|   const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) |     0, | ||||||
|    |     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, | ||||||
|  |   ) | ||||||
|  |   const mainScrollable = Math.max( | ||||||
|  |     0, | ||||||
|  |     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, | ||||||
|  |   ) | ||||||
|  |  | ||||||
|   if (jogScrollable > 0) { |   if (jogScrollable > 0) { | ||||||
|     const ratio = jogwheelViewport.value.scrollTop / jogScrollable |     const ratio = jogwheelViewport.value.scrollTop / jogScrollable | ||||||
|      |  | ||||||
|     // Emit scroll event to parent to update main viewport |     // Emit scroll event to parent to update main viewport | ||||||
|     emit('scroll-to', ratio * mainScrollable) |     emit('scroll-to', ratio * mainScrollable) | ||||||
|   } |   } | ||||||
|    |  | ||||||
|   setTimeout(() => { |   setTimeout(() => { | ||||||
|     if (syncLock.value === 'main') syncLock.value = null |     if (syncLock.value === 'main') syncLock.value = null | ||||||
|   }, 50) |   }, 50) | ||||||
| @@ -53,29 +142,38 @@ const syncFromJogwheel = () => { | |||||||
| const syncFromMain = (mainScrollTop) => { | const syncFromMain = (mainScrollTop) => { | ||||||
|   if (!jogwheelViewport.value || !jogwheelContent.value) return |   if (!jogwheelViewport.value || !jogwheelContent.value) return | ||||||
|   if (syncLock.value === 'main') return |   if (syncLock.value === 'main') return | ||||||
|    |  | ||||||
|   syncLock.value = 'jogwheel' |   syncLock.value = 'jogwheel' | ||||||
|    |  | ||||||
|   const mainScrollable = Math.max(0, props.totalVirtualWeeks * props.rowHeight - props.viewportHeight) |   const mainScrollable = Math.max( | ||||||
|   const jogScrollable = Math.max(0, jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight) |     0, | ||||||
|    |     props.totalVirtualWeeks * props.rowHeight - props.viewportHeight, | ||||||
|  |   ) | ||||||
|  |   const jogScrollable = Math.max( | ||||||
|  |     0, | ||||||
|  |     jogwheelContent.value.scrollHeight - jogwheelViewport.value.clientHeight, | ||||||
|  |   ) | ||||||
|  |  | ||||||
|   if (mainScrollable > 0) { |   if (mainScrollable > 0) { | ||||||
|     const ratio = mainScrollTop / mainScrollable |     const ratio = mainScrollTop / mainScrollable | ||||||
|     jogwheelViewport.value.scrollTop = ratio * jogScrollable |     jogwheelViewport.value.scrollTop = ratio * jogScrollable | ||||||
|   } |   } | ||||||
|    |  | ||||||
|   setTimeout(() => { |   setTimeout(() => { | ||||||
|     if (syncLock.value === 'jogwheel') syncLock.value = null |     if (syncLock.value === 'jogwheel') syncLock.value = null | ||||||
|   }, 50) |   }, 50) | ||||||
| } | } | ||||||
|  |  | ||||||
| // Watch for main calendar scroll changes | // Watch for main calendar scroll changes | ||||||
| watch(() => props.scrollTop, (newScrollTop) => { | watch( | ||||||
|   syncFromMain(newScrollTop) |   () => props.scrollTop, | ||||||
| }) |   (newScrollTop) => { | ||||||
|  |     syncFromMain(newScrollTop) | ||||||
|  |   }, | ||||||
|  | ) | ||||||
|  |  | ||||||
| defineExpose({ | defineExpose({ | ||||||
|   syncFromMain |   syncFromMain, | ||||||
| }) | }) | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| @@ -85,20 +183,12 @@ defineExpose({ | |||||||
|   top: 0; |   top: 0; | ||||||
|   right: 0; |   right: 0; | ||||||
|   bottom: 0; |   bottom: 0; | ||||||
|   width: 3rem; /* Use fixed width since minmax() doesn't work for absolute positioning */ |   width: var(--month-w); | ||||||
|   overflow-y: auto; |   overflow-y: auto; | ||||||
|   overflow-x: hidden; |   overflow-x: hidden; | ||||||
|   scrollbar-width: none; |   scrollbar-width: none; | ||||||
|   z-index: 20; |   z-index: 20; | ||||||
|   cursor: ns-resize; |   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 { | .jogwheel-viewport::-webkit-scrollbar { | ||||||
|   | |||||||
| @@ -7,21 +7,20 @@ | |||||||
|     role="spinbutton" |     role="spinbutton" | ||||||
|     :aria-valuemin="minValue" |     :aria-valuemin="minValue" | ||||||
|     :aria-valuemax="maxValue" |     :aria-valuemax="maxValue" | ||||||
|     :aria-valuenow="isPrefix(current) ? undefined : current" |     :aria-valuenow="isPrefix(model) ? undefined : model" | ||||||
|     :aria-valuetext="display" |     :aria-valuetext="display" | ||||||
|     tabindex="0" |     tabindex="0" | ||||||
|     @pointerdown="onPointerDown" |     @pointerdown="onPointerDown" | ||||||
|     @keydown="onKey" |     @keydown="onKey" | ||||||
|  |     @wheel.prevent="onWheel" | ||||||
|   > |   > | ||||||
|     <span class="value" :title="String(current)">{{ display }}</span> |     <span class="value" :title="String(model)">{{ display }}</span> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { computed, ref } from 'vue' | import { computed, ref } from 'vue' | ||||||
|  | const model = defineModel({ default: 0 }) | ||||||
| const model = defineModel({ type: Number, default: 0 }) |  | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   min: { type: Number, default: 0 }, |   min: { type: Number, default: 0 }, | ||||||
|   max: { type: Number, default: 999 }, |   max: { type: Number, default: 999 }, | ||||||
| @@ -36,111 +35,122 @@ const props = defineProps({ | |||||||
|   numberPostfix: { type: String, default: '' }, |   numberPostfix: { type: String, default: '' }, | ||||||
|   clamp: { type: Boolean, default: true }, |   clamp: { type: Boolean, default: true }, | ||||||
|   pixelsPerStep: { type: Number, default: 16 }, |   pixelsPerStep: { type: Number, default: 16 }, | ||||||
|   // Movement now restricted to horizontal (x). Prop retained for compatibility but ignored. |  | ||||||
|   axis: { type: String, default: 'x' }, |   axis: { type: String, default: 'x' }, | ||||||
|   ariaLabel: { type: String, default: '' }, |   ariaLabel: { type: String, default: '' }, | ||||||
|   extraClass: { type: String, default: '' }, |   extraClass: { type: String, default: '' }, | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const minValue = computed(() => props.min) | const minValue = computed(() => props.min) | ||||||
| const maxValue = computed(() => props.max) | const maxValue = computed(() => props.max) | ||||||
|  | const isPrefix = (value) => props.prefixValues.some((p) => p.value === value) | ||||||
| // Helper to check if a value is in the prefix values | const getPrefixDisplay = (value) => | ||||||
| const isPrefix = (value) => { |   props.prefixValues.find((p) => p.value === value)?.display ?? null | ||||||
|   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 allValidValues = computed(() => { | const allValidValues = computed(() => { | ||||||
|   const prefixVals = props.prefixValues.map((p) => p.value) |   const prefixVals = props.prefixValues.map((p) => p.value) | ||||||
|   const numericVals = [] |   const numericVals = [] | ||||||
|   for (let i = props.min; i <= props.max; i += props.step) { |   for (let i = props.min; i <= props.max; i += props.step) numericVals.push(i) | ||||||
|     numericVals.push(i) |  | ||||||
|   } |  | ||||||
|   return [...prefixVals, ...numericVals] |   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 display = computed(() => { | ||||||
|   const prefixDisplay = getPrefixDisplay(current.value) |   const prefixDisplay = getPrefixDisplay(model.value) | ||||||
|   if (prefixDisplay !== null) { |   if (prefixDisplay !== null) return prefixDisplay | ||||||
|     // For prefix values, show only the display text without number prefix/postfix |   return `${props.numberPrefix}${String(model.value)}${props.numberPostfix}` | ||||||
|     return prefixDisplay |  | ||||||
|   } |  | ||||||
|   // For numeric values, include prefix and postfix |  | ||||||
|   const numericValue = String(current.value) |  | ||||||
|   return `${props.numberPrefix}${numericValue}${props.numberPostfix}` |  | ||||||
| }) | }) | ||||||
|  |  | ||||||
| // Drag handling |  | ||||||
| const dragging = ref(false) | const dragging = ref(false) | ||||||
| const rootEl = ref(null) | const rootEl = ref(null) | ||||||
| let startX = 0 | let startX = 0 | ||||||
| let startY = 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) { | function onPointerDown(e) { | ||||||
|   e.preventDefault() |   e.preventDefault() | ||||||
|   startX = e.clientX |   startX = e.clientX | ||||||
|   startY = e.clientY |   startY = e.clientY | ||||||
|   startVal = current.value |   lastClientX = e.clientX | ||||||
|  |   accumX = 0 | ||||||
|   dragging.value = true |   dragging.value = true | ||||||
|   try { |   try { | ||||||
|     e.currentTarget.setPointerCapture(e.pointerId) |     e.currentTarget.setPointerCapture?.(e.pointerId) | ||||||
|   } catch {} |   } catch {} | ||||||
|   rootEl.value?.addEventListener('pointermove', onPointerMove) |   if (e.pointerType === 'mouse' && rootEl.value?.requestPointerLock) { | ||||||
|   rootEl.value?.addEventListener('pointerup', onPointerUp, { once: true }) |     addPointerLockListeners() | ||||||
|   rootEl.value?.addEventListener('pointercancel', onPointerCancel, { once: true }) |     try { | ||||||
|  |       rootEl.value.requestPointerLock() | ||||||
|  |     } catch {} | ||||||
|  |   } | ||||||
|  |   document.addEventListener('pointermove', onPointerMove) | ||||||
|  |   document.addEventListener('pointerup', onPointerUp, { once: true }) | ||||||
|  |   document.addEventListener('pointercancel', onPointerCancel, { once: true }) | ||||||
| } | } | ||||||
| function onPointerMove(e) { | function onPointerMove(e) { | ||||||
|   if (!dragging.value) return |   if (!dragging.value) return | ||||||
|   // Prevent page scroll on touch while dragging |  | ||||||
|   if (e.pointerType === 'touch') e.preventDefault() |   if (e.pointerType === 'touch') e.preventDefault() | ||||||
|   const primary = e.clientX - startX // horizontal only |   let dx = pointerLocked.value ? e.movementX || 0 : e.clientX - lastClientX | ||||||
|   const steps = Math.trunc(primary / props.pixelsPerStep) |   if (!pointerLocked.value) lastClientX = e.clientX | ||||||
|  |   if (!dx) return | ||||||
|   // Find current value index in all valid values |   accumX += dx | ||||||
|   const currentIndex = allValidValues.value.indexOf(startVal) |   const stepSize = props.pixelsPerStep || 1 | ||||||
|   if (currentIndex === -1) return // shouldn't happen |   let steps = Math.trunc(accumX / stepSize) | ||||||
|  |   if (steps === 0) return | ||||||
|   const newIndex = currentIndex + steps |   const applySteps = (count) => { | ||||||
|   if (props.clamp) { |     if (!count) return | ||||||
|     const clampedIndex = Math.max(0, Math.min(newIndex, allValidValues.value.length - 1)) |     let direction = count > 0 ? 1 : -1 | ||||||
|     const next = allValidValues.value[clampedIndex] |     let remaining = Math.abs(count) | ||||||
|     if (next !== current.value) current.value = next |     let curVal = model.value | ||||||
|   } else { |     const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal) | ||||||
|     if (newIndex >= 0 && newIndex < allValidValues.value.length) { |     let idx = allValidValues.value.indexOf(curVal) | ||||||
|       const next = allValidValues.value[newIndex] |     if (idx === -1) { | ||||||
|       if (next !== current.value) current.value = next |       if (!isNumeric) { | ||||||
|  |         curVal = props.prefixValues.length ? props.prefixValues[0].value : props.min | ||||||
|  |       } else { | ||||||
|  |         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() { | function endDragListeners() { | ||||||
|   rootEl.value?.removeEventListener('pointermove', onPointerMove) |   document.removeEventListener('pointermove', onPointerMove) | ||||||
|  |   if (pointerLocked.value && document.exitPointerLock) { | ||||||
|  |     try { | ||||||
|  |       document.exitPointerLock() | ||||||
|  |     } catch {} | ||||||
|  |   } | ||||||
|  |   removePointerLockListeners() | ||||||
| } | } | ||||||
| function onPointerUp() { | function onPointerUp() { | ||||||
|   dragging.value = false |   dragging.value = false | ||||||
| @@ -150,52 +160,43 @@ function onPointerCancel() { | |||||||
|   dragging.value = false |   dragging.value = false | ||||||
|   endDragListeners() |   endDragListeners() | ||||||
| } | } | ||||||
|  |  | ||||||
| function onKey(e) { | function onKey(e) { | ||||||
|   const key = e.key |   const key = e.key | ||||||
|   let handled = false |   let handled = false | ||||||
|   let newValue = null |   let newValue = null | ||||||
|  |   const currentIndex = allValidValues.value.indexOf(model.value) | ||||||
|   // Find current value index in all valid values |  | ||||||
|   const currentIndex = allValidValues.value.indexOf(current.value) |  | ||||||
|  |  | ||||||
|   switch (key) { |   switch (key) { | ||||||
|     case 'ArrowRight': |     case 'ArrowRight': | ||||||
|     case 'ArrowUp': |     case 'ArrowUp': | ||||||
|       if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) { |       if (currentIndex !== -1 && currentIndex < allValidValues.value.length - 1) | ||||||
|         newValue = allValidValues.value[currentIndex + 1] |         newValue = allValidValues.value[currentIndex + 1] | ||||||
|       } else if (currentIndex === -1) { |       else if (currentIndex === -1) { | ||||||
|         // Current value not in list, try to increment normally |         const curVal = model.value | ||||||
|         newValue = current.value + props.step |         const isNumeric = typeof curVal === 'number' && !Number.isNaN(curVal) | ||||||
|  |         if (!isNumeric && props.prefixValues.length) newValue = props.prefixValues[0].value | ||||||
|  |         else newValue = props.min | ||||||
|       } |       } | ||||||
|       handled = true |       handled = true | ||||||
|       break |       break | ||||||
|     case 'ArrowLeft': |     case 'ArrowLeft': | ||||||
|     case 'ArrowDown': |     case 'ArrowDown': | ||||||
|       if (currentIndex !== -1 && currentIndex > 0) { |       if (currentIndex !== -1 && currentIndex > 0) newValue = allValidValues.value[currentIndex - 1] | ||||||
|         newValue = allValidValues.value[currentIndex - 1] |       else if (currentIndex === -1) | ||||||
|       } else if (currentIndex === -1) { |         newValue = props.prefixValues.length | ||||||
|         // Current value not in list, try to decrement normally |           ? props.prefixValues[props.prefixValues.length - 1].value | ||||||
|         newValue = current.value - props.step |           : props.min | ||||||
|       } |  | ||||||
|       handled = true |       handled = true | ||||||
|       break |       break | ||||||
|     case 'PageUp': |     case 'PageUp': | ||||||
|       if (currentIndex !== -1) { |       if (currentIndex !== -1) | ||||||
|         const newIndex = Math.min(currentIndex + 10, allValidValues.value.length - 1) |         newValue = | ||||||
|         newValue = allValidValues.value[newIndex] |           allValidValues.value[Math.min(currentIndex + 10, allValidValues.value.length - 1)] | ||||||
|       } else { |       else newValue = model.value + props.step * 10 | ||||||
|         newValue = current.value + props.step * 10 |  | ||||||
|       } |  | ||||||
|       handled = true |       handled = true | ||||||
|       break |       break | ||||||
|     case 'PageDown': |     case 'PageDown': | ||||||
|       if (currentIndex !== -1) { |       if (currentIndex !== -1) newValue = allValidValues.value[Math.max(currentIndex - 10, 0)] | ||||||
|         const newIndex = Math.max(currentIndex - 10, 0) |       else newValue = model.value - props.step * 10 | ||||||
|         newValue = allValidValues.value[newIndex] |  | ||||||
|       } else { |  | ||||||
|         newValue = current.value - props.step * 10 |  | ||||||
|       } |  | ||||||
|       handled = true |       handled = true | ||||||
|       break |       break | ||||||
|     case 'Home': |     case 'Home': | ||||||
| @@ -207,16 +208,32 @@ function onKey(e) { | |||||||
|       handled = true |       handled = true | ||||||
|       break |       break | ||||||
|   } |   } | ||||||
|  |   if (newValue !== null) model.value = newValue | ||||||
|   if (newValue !== null) { |  | ||||||
|     current.value = newValue |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   if (handled) { |   if (handled) { | ||||||
|     e.preventDefault() |     e.preventDefault() | ||||||
|     e.stopPropagation() |     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> | </script> | ||||||
|  |  | ||||||
| <style scoped> | <style scoped> | ||||||
| @@ -226,18 +243,14 @@ function onKey(e) { | |||||||
|   display: inline-flex; |   display: inline-flex; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
|   padding: 0 0.4rem; |  | ||||||
|   gap: 0.25rem; |   gap: 0.25rem; | ||||||
|   border: 1px solid var(--input-border, var(--muted)); |   background: none; | ||||||
|   background: var(--panel-alt); |  | ||||||
|   border-radius: 0.4rem; |  | ||||||
|   min-height: 1.8rem; |  | ||||||
|   font-variant-numeric: tabular-nums; |   font-variant-numeric: tabular-nums; | ||||||
|   touch-action: none; /* allow custom drag without scrolling */ |   touch-action: none; | ||||||
| } | } | ||||||
| .mini-stepper.drag-mode:focus-visible { | .mini-stepper.drag-mode:focus-visible { | ||||||
|   outline: 2px solid var(--input-focus, #2563eb); |   box-shadow: 0 0 0 2px var(--input-focus, #2563eb); | ||||||
|   outline-offset: 2px; |   outline: none; | ||||||
| } | } | ||||||
| .mini-stepper.drag-mode .value { | .mini-stepper.drag-mode .value { | ||||||
|   font-weight: 600; |   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="week-label">W{{ weekNumber }}</div> | ||||||
|     <div class="days-grid"> |     <div class="days-grid"> | ||||||
|       <DayCell v-for="day in days" :key="day.dateStr" :day="day" /> |       <DayCell v-for="day in days" :key="day.dateStr" :day="day" /> | ||||||
|       <div class="week-overlay"> |       <div class="week-overlay"></div> | ||||||
|         <!-- Event spans will be rendered here --> |  | ||||||
|       </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> |       <span>{{ monthLabel.name }} '{{ monthLabel.year }}</span> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| @@ -16,51 +18,56 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { computed } from 'vue' | import { computed } from 'vue' | ||||||
| import DayCell from './DayCell.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({ | const props = defineProps({ | ||||||
|   week: { |   week: { | ||||||
|     type: Object, |     type: Object, | ||||||
|     required: true |     required: true, | ||||||
|   } |   }, | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const weekNumber = computed(() => { | const weekNumber = computed(() => getISOWeek(props.week.monday)) | ||||||
|   return isoWeekInfo(props.week.monday).week |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| const days = computed(() => { | const days = computed(() => { | ||||||
|   const d = new Date(props.week.monday) |   const d = new Date(props.week.monday) | ||||||
|   const result = [] |   const result = [] | ||||||
|   for (let i = 0; i < 7; i++) { |   for (let i = 0; i < 7; i++) { | ||||||
|     const dateStr = toLocalString(d) |     const dateStr = toLocalString(d, DEFAULT_TZ) | ||||||
|     result.push({ |     result.push({ | ||||||
|       date: new Date(d), |       date: new Date(d), | ||||||
|       dateStr, |       dateStr, | ||||||
|       dayOfMonth: d.getDate(), |       dayOfMonth: d.getDate(), | ||||||
|       month: d.getMonth(), |       month: d.getMonth(), | ||||||
|       isFirstDayOfMonth: d.getDate() === 1, |       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 |   return result | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const monthLabel = computed(() => { | const monthLabel = computed(() => { | ||||||
|   const firstDayOfMonth = days.value.find(d => d.isFirstDayOfMonth) |   const firstDayOfMonth = days.value.find((d) => d.isFirstDayOfMonth) | ||||||
|   if (!firstDayOfMonth) return null |   if (!firstDayOfMonth) return null | ||||||
|  |  | ||||||
|   const month = firstDayOfMonth.month |   const month = firstDayOfMonth.month | ||||||
|   const year = firstDayOfMonth.date.getFullYear() |   const year = firstDayOfMonth.date.getFullYear() | ||||||
|    |  | ||||||
|   // This is a simplified calculation for weeksSpan |   // This is a simplified calculation for weeksSpan | ||||||
|   const weeksSpan = 4  |   const weeksSpan = 4 | ||||||
|  |  | ||||||
|   return { |   return { | ||||||
|     name: getLocalizedMonthName(month), |     name: getLocalizedMonthName(month), | ||||||
|     year: String(year).slice(-2), |     year: String(year).slice(-2), | ||||||
|     weeksSpan |     weeksSpan, | ||||||
|   } |   } | ||||||
| }) | }) | ||||||
| </script> | </script> | ||||||
|   | |||||||
| @@ -33,7 +33,7 @@ | |||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <script setup> | <script setup> | ||||||
| import { computed, ref } from 'vue' | import { computed, ref, watch } from 'vue' | ||||||
| import { | import { | ||||||
|   getLocalizedWeekdayNames, |   getLocalizedWeekdayNames, | ||||||
|   getLocaleFirstDay, |   getLocaleFirstDay, | ||||||
| @@ -44,7 +44,10 @@ import { | |||||||
| const model = defineModel({ | const model = defineModel({ | ||||||
|   type: Array, |   type: Array, | ||||||
|   default: () => [false, false, false, false, false, false, false], |   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({ | const props = defineProps({ | ||||||
|   weekend: { type: Array, default: undefined }, |   weekend: { type: Array, default: undefined }, | ||||||
| @@ -55,12 +58,11 @@ const props = defineProps({ | |||||||
|   firstDay: { type: Number, default: null }, |   firstDay: { type: Number, default: null }, | ||||||
| }) | }) | ||||||
|  |  | ||||||
| // If external model provided is entirely false, keep as-is (user will see fallback styling), | // Initialize internal from external if it has any true; else keep empty (fallback handled on emit) | ||||||
| // only overwrite if null/undefined. | if (model.value?.some?.(Boolean)) internal.value = [...model.value] | ||||||
| if (!model.value) model.value = [...props.fallback] |  | ||||||
| const labelsMondayFirst = getLocalizedWeekdayNames() | const labelsMondayFirst = getLocalizedWeekdayNames() | ||||||
| const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)] | 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 localeFirst = getLocaleFirstDay() | ||||||
| const localeWeekend = getLocaleWeekendDays() | const localeWeekend = getLocaleWeekendDays() | ||||||
| const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7) | const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7) | ||||||
| @@ -71,10 +73,38 @@ const weekendDays = computed(() => { | |||||||
| }) | }) | ||||||
|  |  | ||||||
| const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value)) | 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 displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value)) | ||||||
| const displayDefault = computed(() => reorderByFirstDay(props.fallback, 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 | // Mapping from display index to original model index | ||||||
| const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7)) | const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7)) | ||||||
|  |  | ||||||
| @@ -135,8 +165,8 @@ function isPressing(di) { | |||||||
| } | } | ||||||
|  |  | ||||||
| function onPointerDown(di) { | function onPointerDown(di) { | ||||||
|   originalValues = [...model.value] |   originalValues = [...internal.value] | ||||||
|   dragVal.value = !model.value[(di + firstDay.value) % 7] |   dragVal.value = !internal.value[(di + firstDay.value) % 7] | ||||||
|   dragStart.value = di |   dragStart.value = di | ||||||
|   previewEnd.value = di |   previewEnd.value = di | ||||||
|   dragging.value = true |   dragging.value = true | ||||||
| @@ -155,7 +185,8 @@ function onPointerUp() { | |||||||
|     // simple click: toggle single |     // simple click: toggle single | ||||||
|     const next = [...originalValues] |     const next = [...originalValues] | ||||||
|     next[(dragStart.value + firstDay.value) % 7] = dragVal.value |     next[(dragStart.value + firstDay.value) % 7] = dragVal.value | ||||||
|     model.value = next |     internal.value = next | ||||||
|  |     emitExternal() | ||||||
|     cleanupDrag() |     cleanupDrag() | ||||||
|   } else { |   } else { | ||||||
|     commitDrag() |     commitDrag() | ||||||
| @@ -169,7 +200,8 @@ function commitDrag() { | |||||||
|       : [previewEnd.value, dragStart.value] |       : [previewEnd.value, dragStart.value] | ||||||
|   const next = [...originalValues] |   const next = [...originalValues] | ||||||
|   for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value |   for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value | ||||||
|   model.value = next |   internal.value = next | ||||||
|  |   emitExternal() | ||||||
|   cleanupDrag() |   cleanupDrag() | ||||||
| } | } | ||||||
| function cancelDrag() { | function cancelDrag() { | ||||||
| @@ -185,14 +217,15 @@ function cleanupDrag() { | |||||||
| function toggleWeekend(work) { | function toggleWeekend(work) { | ||||||
|   const base = weekendDays.value |   const base = weekendDays.value | ||||||
|   const target = work ? base : base.map((v) => !v) |   const target = work ? base : base.map((v) => !v) | ||||||
|   const current = model.value |   const current = internal.value | ||||||
|   const allOn = current.every(Boolean) |   const allOn = current.every(Boolean) | ||||||
|   const isTargetActive = current.every((v, i) => v === target[i]) |   const isTargetActive = current.every((v, i) => v === target[i]) | ||||||
|   if (allOn || isTargetActive) { |   if (allOn || isTargetActive) { | ||||||
|     model.value = [false, false, false, false, false, false, false] |     internal.value = [false, false, false, false, false, false, false] | ||||||
|   } else { |   } else { | ||||||
|     model.value = [...target] |     internal.value = [...target] | ||||||
|   } |   } | ||||||
|  |   emitExternal() | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,11 +2,17 @@ import './assets/calendar.css' | |||||||
|  |  | ||||||
| import { createApp } from 'vue' | import { createApp } from 'vue' | ||||||
| import { createPinia } from 'pinia' | import { createPinia } from 'pinia' | ||||||
|  | import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' | ||||||
|  | import { calendarHistory } from '@/plugins/calendarHistory' | ||||||
|  |  | ||||||
| import App from './App.vue' | import App from './App.vue' | ||||||
|  |  | ||||||
| const app = createApp(App) | 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') | 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 { | import { | ||||||
|   toLocalString, |   toLocalString, | ||||||
|   fromLocalString, |   fromLocalString, | ||||||
|   getLocaleFirstDay, |  | ||||||
|   getLocaleWeekendDays, |   getLocaleWeekendDays, | ||||||
|  |   getMondayOfISOWeek, | ||||||
|  |   getOccurrenceDate, | ||||||
|  |   DEFAULT_TZ, | ||||||
| } from '@/utils/date' | } from '@/utils/date' | ||||||
|  | import { differenceInCalendarDays, addDays } from 'date-fns' | ||||||
| /** | import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays' | ||||||
|  * 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() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const useCalendarStore = defineStore('calendar', { | export const useCalendarStore = defineStore('calendar', { | ||||||
|   state: () => ({ |   state: () => ({ | ||||||
|     today: toLocalString(new Date()), |     today: toLocalString(new Date(), DEFAULT_TZ), | ||||||
|     now: new Date(), |     now: new Date().toISOString(), | ||||||
|     events: new Map(), // Map of date strings to arrays of events |     events: new Map(), | ||||||
|     weekend: getConfiguredWeekendDays(), |     // 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: { |     config: { | ||||||
|       select_days: 1000, |       select_days: 14, | ||||||
|       min_year: MIN_YEAR, |       first_day: 1, | ||||||
|       max_year: MAX_YEAR, |       holidays: { | ||||||
|       first_day: getConfiguredFirstDay(), |         enabled: true, | ||||||
|  |         country: 'auto', | ||||||
|  |         state: null, | ||||||
|  |         region: null, | ||||||
|  |       }, | ||||||
|     }, |     }, | ||||||
|   }), |   }), | ||||||
|  |  | ||||||
|   getters: { |  | ||||||
|     // Basic configuration getters |  | ||||||
|     minYear: () => MIN_YEAR, |  | ||||||
|     maxYear: () => MAX_YEAR, |  | ||||||
|   }, |  | ||||||
|  |  | ||||||
|   actions: { |   actions: { | ||||||
|     updateCurrentDate() { |     _rotateWeekdayPattern(pattern, shift) { | ||||||
|       this.now = new Date() |       const k = (7 - (shift % 7)) % 7 | ||||||
|       const today = toLocalString(this.now) |       return pattern.slice(k).concat(pattern.slice(0, k)) | ||||||
|       if (this.today !== today) { |     }, | ||||||
|         this.today = today |     _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() { |     generateId() { | ||||||
|       try { |       try { | ||||||
|         if (window.crypto && typeof window.crypto.randomUUID === 'function') { |         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) |       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) { |     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 = { |       const event = { | ||||||
|         id: this.generateId(), |         id: this.generateId(), | ||||||
|         title: eventData.title, |         title: eventData.title, | ||||||
|         startDate: eventData.startDate, |         startDate: eventData.startDate, | ||||||
|         endDate: eventData.endDate, |         days, | ||||||
|         colorId: |         colorId: | ||||||
|           eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), |           eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.startDate), | ||||||
|         startTime: singleDay ? eventData.startTime || '09:00' : null, |         startTime: singleDay ? eventData.startTime || '09:00' : null, | ||||||
|         durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, |         durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, | ||||||
|         repeat: |         recur: | ||||||
|           (eventData.repeat === 'weekly' |           eventData.recur && ['weeks', 'months'].includes(eventData.recur.freq) | ||||||
|             ? 'weeks' |             ? { | ||||||
|             : eventData.repeat === 'monthly' |                 freq: eventData.recur.freq, | ||||||
|               ? 'months' |                 interval: eventData.recur.interval || 1, | ||||||
|               : eventData.repeat) || 'none', |                 count: eventData.recur.count ?? 'unlimited', | ||||||
|         repeatInterval: eventData.repeatInterval || 1, |                 weekdays: Array.isArray(eventData.recur.weekdays) | ||||||
|         repeatCount: eventData.repeatCount || 'unlimited', |                   ? [...eventData.recur.weekdays] | ||||||
|         repeatWeekdays: eventData.repeatWeekdays, |                   : null, | ||||||
|         isRepeating: eventData.repeat && eventData.repeat !== 'none', |               } | ||||||
|  |             : null, | ||||||
|       } |       } | ||||||
|  |       this.events.set(event.id, { ...event, isSpanning: event.days > 1 }) | ||||||
|       const startDate = new Date(fromLocalString(event.startDate)) |       this.notifyEventsChanged() | ||||||
|       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, []) |  | ||||||
|         } |  | ||||||
|         this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate }) |  | ||||||
|       } |  | ||||||
|       // No physical expansion; repeats are virtual |  | ||||||
|       return event.id |       return event.id | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     getEventById(id) { |     getEventById(id) { | ||||||
|       for (const [, list] of this.events) { |       return this.events.get(id) || null | ||||||
|         const found = list.find((e) => e.id === id) |  | ||||||
|         if (found) return found |  | ||||||
|       } |  | ||||||
|       return null |  | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     selectEventColorId(startDateStr, endDateStr) { |     selectEventColorId(startDateStr, endDateStr) { | ||||||
|       const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] |       const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] | ||||||
|       const startDate = new Date(fromLocalString(startDateStr)) |       const startDate = fromLocalString(startDateStr, DEFAULT_TZ) | ||||||
|       const endDate = new Date(fromLocalString(endDateStr)) |       const endDate = fromLocalString(endDateStr, DEFAULT_TZ) | ||||||
|  |       for (const ev of this.events.values()) { | ||||||
|       for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { |         const evStart = fromLocalString(ev.startDate) | ||||||
|         const dateStr = toLocalString(d) |         const evEnd = addDays(evStart, (ev.days || 1) - 1) | ||||||
|         const dayEvents = this.events.get(dateStr) || [] |         if (evEnd < startDate || evStart > endDate) continue | ||||||
|         for (const event of dayEvents) { |         if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++ | ||||||
|           if (event.colorId >= 0 && event.colorId < 8) { |  | ||||||
|             colorCounts[event.colorId]++ |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       let minCount = colorCounts[0] |       let minCount = colorCounts[0] | ||||||
|       let selectedColor = 0 |       let selectedColor = 0 | ||||||
|  |       for (let c = 1; c < 8; c++) { | ||||||
|       for (let colorId = 1; colorId < 8; colorId++) { |         if (colorCounts[c] < minCount) { | ||||||
|         if (colorCounts[colorId] < minCount) { |           minCount = colorCounts[c] | ||||||
|           minCount = colorCounts[colorId] |           selectedColor = c | ||||||
|           selectedColor = colorId |  | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       return selectedColor |       return selectedColor | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     deleteEvent(eventId) { |     deleteEvent(eventId) { | ||||||
|       const datesToCleanup = [] |       this.events.delete(eventId) | ||||||
|       for (const [dateStr, eventList] of this.events) { |       this.notifyEventsChanged() | ||||||
|         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) |  | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     deleteFirstOccurrence(baseId) { |     deleteFirstOccurrence(baseId) { | ||||||
|       const base = this.getEventById(baseId) |       const base = this.getEventById(baseId) | ||||||
|       if (!base || !base.isRepeating) return |       if (!base) return | ||||||
|       const oldStart = new Date(fromLocalString(base.startDate)) |       if (!base.recur) { | ||||||
|       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 |  | ||||||
|         this.deleteEvent(baseId) |         this.deleteEvent(baseId) | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
|  |       const numericCount = | ||||||
|       if (!newStart) { |         base.recur.count === 'unlimited' ? Infinity : parseInt(base.recur.count, 10) | ||||||
|         // No subsequent occurrence -> delete entire series |       if (numericCount <= 1) { | ||||||
|         this.deleteEvent(baseId) |         this.deleteEvent(baseId) | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
|  |       const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ) | ||||||
|       if (base.repeatCount !== 'unlimited') { |       if (!nextStartStr) { | ||||||
|         const rc = parseInt(base.repeatCount, 10) |         this.deleteEvent(baseId) | ||||||
|         if (!isNaN(rc)) { |         return | ||||||
|           const newRc = Math.max(0, rc - 1) |  | ||||||
|           if (newRc === 0) { |  | ||||||
|             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) |     deleteSingleOccurrence(ctx) { | ||||||
|       newEnd.setDate(newEnd.getDate() + spanDays) |       const { baseId, occurrenceIndex } = ctx || {} | ||||||
|       base.startDate = toLocalString(newStart) |       if (occurrenceIndex == null) return | ||||||
|       base.endDate = toLocalString(newEnd) |       const base = this.getEventById(baseId) | ||||||
|       // old occurrence expansion removed (series handled differently now) |       if (!base) return | ||||||
|       const originalRepeatCount = base.repeatCount |       if (!base.recur) { | ||||||
|       // Always cap original series at the split occurrence index (occurrences 0..index-1) |         if (occurrenceIndex === 0) this.deleteEvent(baseId) | ||||||
|       // Keep its weekday pattern unchanged. |         return | ||||||
|       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' |  | ||||||
|         } |  | ||||||
|       } else { |  | ||||||
|         // Original was unlimited: original now capped, new stays unlimited |  | ||||||
|         newRepeatCount = 'unlimited' |  | ||||||
|       } |       } | ||||||
|  |       if (occurrenceIndex === 0) { | ||||||
|       // Handle weekdays for weekly repeats |         this.deleteFirstOccurrence(baseId) | ||||||
|       let newRepeatWeekdays = base.repeatWeekdays |         return | ||||||
|       if (base.repeat === 'weeks' && base.repeatWeekdays) { |       } | ||||||
|         const newStartDate = new Date(fromLocalString(startDate)) |       const snapshot = { ...base } | ||||||
|         let dayShift = 0 |       snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null | ||||||
|         if (grabbedWeekday != null) { |       base.recur.count = occurrenceIndex | ||||||
|           // Rotate so that the grabbed weekday maps to the new start weekday |       const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ) | ||||||
|           dayShift = newStartDate.getDay() - grabbedWeekday |       if (!nextStartStr) return | ||||||
|         } else { |       const originalNumeric = | ||||||
|           // Fallback: rotate by difference between new and original start weekday |         snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10) | ||||||
|           const originalStartDate = new Date(fromLocalString(base.startDate)) |       let remainingCount = 'unlimited' | ||||||
|           dayShift = newStartDate.getDay() - originalStartDate.getDay() |       if (originalNumeric !== Infinity) { | ||||||
|         } |         const rem = originalNumeric - (occurrenceIndex + 1) | ||||||
|         if (dayShift !== 0) { |         if (rem <= 0) return | ||||||
|           const rotatedWeekdays = [false, false, false, false, false, false, false] |         remainingCount = String(rem) | ||||||
|           for (let i = 0; i < 7; i++) { |       } | ||||||
|             if (base.repeatWeekdays[i]) { |       this.createEvent({ | ||||||
|               let nd = (i + dayShift) % 7 |         title: snapshot.title, | ||||||
|               if (nd < 0) nd += 7 |         startDate: nextStartStr, | ||||||
|               rotatedWeekdays[nd] = true |         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, | ||||||
|             } |             } | ||||||
|           } |           : null, | ||||||
|           newRepeatWeekdays = rotatedWeekdays |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       const newId = this.createEvent({ |  | ||||||
|         title: base.title, |  | ||||||
|         startDate, |  | ||||||
|         endDate, |  | ||||||
|         colorId: base.colorId, |  | ||||||
|         repeat: base.repeat, |  | ||||||
|         repeatCount: newRepeatCount, |  | ||||||
|         repeatWeekdays: newRepeatWeekdays, |  | ||||||
|       }) |       }) | ||||||
|       return newId |       this.notifyEventsChanged() | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     _snapshotBaseEvent(eventId) { |     deleteFromOccurrence(ctx) { | ||||||
|       // Return a shallow snapshot of any instance for metadata |       const { baseId, occurrenceIndex } = ctx | ||||||
|       for (const [, eventList] of this.events) { |       const base = this.getEventById(baseId) | ||||||
|         const e = eventList.find((x) => x.id === eventId) |       if (!base || !base.recur) return | ||||||
|         if (e) return { ...e } |       if (occurrenceIndex === 0) { | ||||||
|  |         this.deleteEvent(baseId) | ||||||
|  |         return | ||||||
|       } |       } | ||||||
|       return null |       this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) | ||||||
|  |       this.notifyEventsChanged() | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     _removeEventFromAllDatesById(eventId) { |     setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto', rotatePattern = true } = {}) { | ||||||
|       for (const [dateStr, list] of this.events) { |       const snapshot = this.events.get(eventId) | ||||||
|         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) |  | ||||||
|       if (!snapshot) return |       if (!snapshot) return | ||||||
|       // Calculate current duration in days (inclusive) |       const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ) | ||||||
|       const prevStart = new Date(fromLocalString(snapshot.startDate)) |       const prevDurationDays = (snapshot.days || 1) - 1 | ||||||
|       const prevEnd = new Date(fromLocalString(snapshot.endDate)) |       const newStart = fromLocalString(newStartStr, DEFAULT_TZ) | ||||||
|       const prevDurationDays = Math.max( |       const newEnd = fromLocalString(newEndStr, DEFAULT_TZ) | ||||||
|         0, |       const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart)) | ||||||
|         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)), |  | ||||||
|       ) |  | ||||||
|  |  | ||||||
|       let finalDurationDays = prevDurationDays |       let finalDurationDays = prevDurationDays | ||||||
|       if (mode === 'resize-left' || mode === 'resize-right') { |       if (mode === 'resize-left' || mode === 'resize-right') | ||||||
|         finalDurationDays = proposedDurationDays |         finalDurationDays = proposedDurationDays | ||||||
|       } |  | ||||||
|  |  | ||||||
|       snapshot.startDate = newStartStr |       snapshot.startDate = newStartStr | ||||||
|       snapshot.endDate = toLocalString( |       snapshot.days = finalDurationDays + 1 | ||||||
|         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 |  | ||||||
|       if ( |       if ( | ||||||
|         mode === 'move' && |         rotatePattern && | ||||||
|         snapshot.isRepeating && |         (mode === 'move' || mode === 'resize-left') && | ||||||
|         snapshot.repeat === 'weeks' && |         snapshot.recur && | ||||||
|         Array.isArray(snapshot.repeatWeekdays) |         snapshot.recur.freq === 'weeks' && | ||||||
|  |         Array.isArray(snapshot.recur.weekdays) | ||||||
|       ) { |       ) { | ||||||
|         const oldDow = prevStart.getDay() |         const oldDow = prevStart.getDay() | ||||||
|         const newDow = newStart.getDay() |         const newDow = newStart.getDay() | ||||||
|         const shift = newDow - oldDow |         const shift = newDow - oldDow | ||||||
|         if (shift !== 0) { |         if (shift !== 0) { | ||||||
|           const rotated = [false, false, false, false, false, false, false] |           snapshot.recur.weekdays = this._rotateWeekdayPattern(snapshot.recur.weekdays, shift) | ||||||
|           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.repeatWeekdays = rotated |  | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       // Reindex |       this.events.set(eventId, { ...snapshot, isSpanning: snapshot.days > 1 }) | ||||||
|       this._removeEventFromAllDatesById(eventId) |       this.notifyEventsChanged() | ||||||
|       this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate) |  | ||||||
|       // no expansion |  | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     // Split a repeating series at a given occurrence index; returns new series id |     splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) { | ||||||
|     splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) { |       const base = this.events.get(baseId) | ||||||
|       const base = this._findEventInAnyList(baseId) |       if (!base || !base.recur) return | ||||||
|       if (!base || !base.isRepeating) return null |       const originalCountRaw = base.recur.count | ||||||
|       // Capture original repeatCount BEFORE truncation |       const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ) | ||||||
|       const originalCountRaw = base.repeatCount |       const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) | ||||||
|       // Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1) |       // 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) |       this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) | ||||||
|       // Compute new series repeatCount (remaining occurrences starting at occurrenceIndex) |  | ||||||
|       let newSeriesCount = 'unlimited' |       let newSeriesCount = 'unlimited' | ||||||
|       if (originalCountRaw !== 'unlimited') { |       if (originalCountRaw !== 'unlimited') { | ||||||
|         const originalNum = parseInt(originalCountRaw, 10) |         const originalNum = parseInt(originalCountRaw, 10) | ||||||
| @@ -440,64 +428,54 @@ export const useCalendarStore = defineStore('calendar', { | |||||||
|           newSeriesCount = String(Math.max(1, remaining)) |           newSeriesCount = String(Math.max(1, remaining)) | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       const newId = this.createEvent({ |       return this.createEvent({ | ||||||
|         title: base.title, |         title: base.title, | ||||||
|         startDate: newStartStr, |         startDate: newStartStr, | ||||||
|         endDate: newEndStr, |         days: base.days, | ||||||
|         colorId: base.colorId, |         colorId: base.colorId, | ||||||
|         repeat: base.repeat, |         recur: base.recur | ||||||
|         repeatInterval: base.repeatInterval, |           ? { | ||||||
|         repeatCount: newSeriesCount, |               freq: base.recur.freq, | ||||||
|         repeatWeekdays: base.repeatWeekdays, |               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) { |     _terminateRepeatSeriesAtIndex(baseId, index) { | ||||||
|       // Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split) |       const ev = this.events.get(baseId) | ||||||
|       for (const [, list] of this.events) { |       if (!ev || !ev.recur) return | ||||||
|         for (const ev of list) { |       if (ev.recur.count === 'unlimited') { | ||||||
|           if (ev.id === baseId && ev.isRepeating) { |         ev.recur.count = String(index) | ||||||
|             if (ev.repeatCount === 'unlimited') { |       } else { | ||||||
|               ev.repeatCount = String(index) |         const rc = parseInt(ev.recur.count, 10) | ||||||
|             } else { |         if (!isNaN(rc)) ev.recur.count = String(Math.min(rc, index)) | ||||||
|               const rc = parseInt(ev.repeatCount, 10) |  | ||||||
|               if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index)) |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       } |       } | ||||||
|  |       this.notifyEventsChanged() | ||||||
|     }, |     }, | ||||||
|  |   }, | ||||||
|     _findEventInAnyList(eventId) { |   persist: { | ||||||
|       for (const [, eventList] of this.events) { |     key: 'calendar-store', | ||||||
|         const found = eventList.find((e) => e.id === eventId) |     storage: localStorage, | ||||||
|         if (found) return found |     paths: ['today', 'config', 'events'], | ||||||
|       } |     serializer: { | ||||||
|       return null |       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 | ||||||
|  |       }, | ||||||
|     }, |     }, | ||||||
|  |  | ||||||
|     _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) |  | ||||||
|       } |  | ||||||
|     }, |  | ||||||
|  |  | ||||||
|     // 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 = [ | const monthAbbr = [ | ||||||
|   'jan', |   'jan', | ||||||
|   'feb', |   'feb', | ||||||
| @@ -13,201 +23,342 @@ const monthAbbr = [ | |||||||
|   'nov', |   'nov', | ||||||
|   'dec', |   'dec', | ||||||
| ] | ] | ||||||
| const DAY_MS = 86400000 | const MIN_YEAR = 100 // less than 100 is interpreted as 19xx | ||||||
| const WEEK_MS = 7 * DAY_MS | const MAX_YEAR = 9999 | ||||||
|  |  | ||||||
|  | // Core helpers ------------------------------------------------------------ | ||||||
| /** | /** | ||||||
|  * Get ISO week information for a given date |  * Construct a date at local midnight in the specified IANA timezone. | ||||||
|  * @param {Date} date - The date to get week info for |  * Returns a native Date whose wall-clock components in that zone are (Y, M, D 00:00:00). | ||||||
|  * @returns {Object} Object containing week number and year |  | ||||||
|  */ |  */ | ||||||
| const isoWeekInfo = (date) => { | function makeTZDate(year, monthIndex, day, timeZone = DEFAULT_TZ) { | ||||||
|   const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())) |   const iso = `${String(year).padStart(4, '0')}-${String(monthIndex + 1).padStart(2, '0')}-${String( | ||||||
|   const day = d.getUTCDay() || 7 |     day, | ||||||
|   d.setUTCDate(d.getUTCDate() + 4 - day) |   ).padStart(2, '0')}` | ||||||
|   const year = d.getUTCFullYear() |   const utcDate = fromZonedTime(`${iso}T00:00:00`, timeZone) | ||||||
|   const yearStart = new Date(Date.UTC(year, 0, 1)) |   return toZonedTime(utcDate, timeZone) | ||||||
|   const diffDays = Math.floor((d - yearStart) / DAY_MS) + 1 |  | ||||||
|   return { week: Math.ceil(diffDays / 7), year } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Convert a Date object to a local date string (YYYY-MM-DD format) |  * Alias constructor for timezone-specific calendar date (semantic sugar over makeTZDate). | ||||||
|  * @param {Date} date - The date to convert (defaults to new Date()) |  | ||||||
|  * @returns {string} Date string in YYYY-MM-DD format |  | ||||||
|  */ |  */ | ||||||
| function toLocalString(date = new Date()) { | const TZDate = (year, monthIndex, day, timeZone = DEFAULT_TZ) => | ||||||
|   const pad = (n) => String(Math.floor(Math.abs(n))).padStart(2, '0') |   makeTZDate(year, monthIndex, day, timeZone) | ||||||
|   return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` |  | ||||||
|  | /** | ||||||
|  |  * 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') | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | function fromLocalString(dateString, timeZone = DEFAULT_TZ) { | ||||||
|  * Convert a local date string (YYYY-MM-DD) to a Date object |   if (!dateString) return makeTZDate(1970, 0, 1, timeZone) | ||||||
|  * @param {string} dateString - Date string in YYYY-MM-DD format |   const parsed = dateFns.parseISO(dateString) | ||||||
|  * @returns {Date} Date object |   const utcDate = fromZonedTime(`${dateString}T00:00:00`, timeZone) | ||||||
|  */ |   return toZonedTime(utcDate, timeZone) || parsed | ||||||
| function fromLocalString(dateString) { |  | ||||||
|   const [year, month, day] = dateString.split('-').map(Number) |  | ||||||
|   return new Date(year, month - 1, day) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { | ||||||
|  * Get the index of Monday for a given date (0-6, where Monday = 0) |   const d = toZonedTime(date, timeZone) | ||||||
|  * @param {Date} d - The date |   const dow = (dateFns.getDay(d) + 6) % 7 // Monday=0 | ||||||
|  * @returns {number} Monday index (0-6) |   return dateFns.addDays(dateFns.startOfDay(d), -dow) | ||||||
|  */ | } | ||||||
| const mondayIndex = (d) => (d.getDay() + 6) % 7 |  | ||||||
|  |  | ||||||
| /** | const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 | ||||||
|  * Pad a number with leading zeros to make it 2 digits |  | ||||||
|  * @param {number} n - Number to pad | // Count how many days in [startDate..endDate] match the boolean `pattern` array | ||||||
|  * @returns {string} Padded string | 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') | const pad = (n) => String(n).padStart(2, '0') | ||||||
|  |  | ||||||
| /** | function daysInclusive(aStr, bStr, timeZone = DEFAULT_TZ) { | ||||||
|  * Calculate number of days between two date strings (inclusive) |   const a = fromLocalString(aStr, timeZone) | ||||||
|  * @param {string} aStr - First date string (YYYY-MM-DD) |   const b = fromLocalString(bStr, timeZone) | ||||||
|  * @param {string} bStr - Second date string (YYYY-MM-DD) |   return ( | ||||||
|  * @returns {number} Number of days inclusive |     Math.abs(dateFns.differenceInCalendarDays(dateFns.startOfDay(a), dateFns.startOfDay(b))) + 1 | ||||||
|  */ |   ) | ||||||
| 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 addDaysStr(str, n, timeZone = DEFAULT_TZ) { | ||||||
|  * Add days to a date string |   return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) | ||||||
|  * @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 getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | ||||||
|  * Get localized weekday names starting from Monday |   const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday | ||||||
|  * @returns {Array<string>} Array of localized weekday names |   return Array.from({ length: 7 }, (_, i) => | ||||||
|  */ |     new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( | ||||||
| function getLocalizedWeekdayNames() { |       dateFns.addDays(monday, i), | ||||||
|   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 |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * 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() { | function getLocaleFirstDay() { | ||||||
|   try { |   const day = new Intl.Locale(navigator.language).weekInfo?.firstDay ?? 1 | ||||||
|     return new Intl.Locale(navigator.language).weekInfo.firstDay % 7 |   return day % 7 | ||||||
|   } catch { |  | ||||||
|     return 1 // Default to Monday if locale info not available |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * 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() { | function getLocaleWeekendDays() { | ||||||
|   try { |   const wk = new Set(new Intl.Locale(navigator.language).weekInfo?.weekend ?? [6, 7]) | ||||||
|     const localeWeekend = new Intl.Locale(navigator.language).weekInfo.weekend |   return Array.from({ length: 7 }, (_, i) => wk.has(1 + ((i + 6) % 7))) | ||||||
|     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 |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * 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) { | function reorderByFirstDay(days, firstDay) { | ||||||
|   return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) |   return Array.from({ length: 7 }, (_, i) => days[(i + firstDay) % 7]) | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | function getLocalizedMonthName(idx, short = false, timeZone = DEFAULT_TZ) { | ||||||
|  * Get localized month name |   const d = makeTZDate(2025, idx, 1, timeZone) | ||||||
|  * @param {number} idx - Month index (0-11) |   return new Intl.DateTimeFormat(undefined, { month: short ? 'short' : 'long', timeZone }).format(d) | ||||||
|  * @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 formatDateRange(startDate, endDate, timeZone = DEFAULT_TZ) { | ||||||
|  * Format a date range for display |   const a = toLocalString(startDate, timeZone) | ||||||
|  * @param {Date} startDate - Start date |   const b = toLocalString(endDate, timeZone) | ||||||
|  * @param {Date} endDate - End date |   if (a === b) return a | ||||||
|  * @returns {string} Formatted date range string |   const [ay, am] = a.split('-') | ||||||
|  */ |   const [by, bm, bd] = b.split('-') | ||||||
| function formatDateRange(startDate, endDate) { |   if (ay === by && am === bm) return `${a}/${bd}` | ||||||
|   if (toLocalString(startDate) === toLocalString(endDate)) return toLocalString(startDate) |   if (ay === by) return `${a}/${bm}-${bd}` | ||||||
|   const startISO = toLocalString(startDate) |   return `${a}/${b}` | ||||||
|   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}` |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /** |  | ||||||
|  * 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) { | function lunarPhaseSymbol(date) { | ||||||
|   // Reference new moon: 2000-01-06 18:14 UTC (J2000 era), often used in approximations |   // Reference new moon (J2000 era) used for approximate phase calculations | ||||||
|   const ref = Date.UTC(2000, 0, 6, 18, 14, 0) |   const ref = UTCDate(2000, 0, 6, 18, 14, 0) | ||||||
|   const synodic = 29.530588853 // days |   const obs = new Date(date) | ||||||
|   // Use UTC noon of given date to reduce timezone edge effects |   obs.setHours(12, 0, 0, 0) | ||||||
|   const dUTC = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12, 0, 0) |   const synodic = 29.530588853 // mean synodic month length in days | ||||||
|   const daysSince = (dUTC - ref) / DAY_MS |   const daysSince = dateFns.differenceInMinutes(obs, ref) / 60 / 24 | ||||||
|   const phase = (((daysSince / synodic) % 1) + 1) % 1 |   const phase = (((daysSince / synodic) % 1) + 1) % 1 // normalize to [0,1) | ||||||
|   const phases = [ |   const phases = [ | ||||||
|     { t: 0.0, s: '🌑' }, // New Moon |     { t: 0.0, s: '🌑' }, // New | ||||||
|     { t: 0.25, s: '🌓' }, // First Quarter |     { t: 0.25, s: '🌓' }, // First Quarter | ||||||
|     { t: 0.5, s: '🌕' }, // Full Moon |     { t: 0.5, s: '🌕' }, // Full | ||||||
|     { t: 0.75, s: '🌗' }, // Last Quarter |     { t: 0.75, s: '🌗' }, // Last Quarter | ||||||
|   ] |   ] | ||||||
|   // threshold in days from exact phase to still count for this date |   const thresholdDays = 0.5 // within ~12h of exact phase | ||||||
|   const thresholdDays = 0.5 // ±12 hours |  | ||||||
|   for (const p of phases) { |   for (const p of phases) { | ||||||
|     let delta = Math.abs(phase - p.t) |     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 |     if (delta * synodic <= thresholdDays) return p.s | ||||||
|   } |   } | ||||||
|   return '' |   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 { | export { | ||||||
|  |   // constants | ||||||
|   monthAbbr, |   monthAbbr, | ||||||
|   DAY_MS, |   MIN_YEAR, | ||||||
|   WEEK_MS, |   MAX_YEAR, | ||||||
|   isoWeekInfo, |   DEFAULT_TZ, | ||||||
|  |   // core tz helpers | ||||||
|  |   makeTZDate, | ||||||
|   toLocalString, |   toLocalString, | ||||||
|   fromLocalString, |   fromLocalString, | ||||||
|  |   // recurrence | ||||||
|  |   getMondayOfISOWeek, | ||||||
|   mondayIndex, |   mondayIndex, | ||||||
|  |   getOccurrenceIndex, | ||||||
|  |   getOccurrenceDate, | ||||||
|  |   getVirtualOccurrenceEndDate, | ||||||
|  |   // formatting & localization | ||||||
|   pad, |   pad, | ||||||
|   daysInclusive, |   daysInclusive, | ||||||
|   addDaysStr, |   addDaysStr, | ||||||
| @@ -217,5 +368,14 @@ export { | |||||||
|   reorderByFirstDay, |   reorderByFirstDay, | ||||||
|   getLocalizedMonthName, |   getLocalizedMonthName, | ||||||
|   formatDateRange, |   formatDateRange, | ||||||
|  |   formatDateShort, | ||||||
|  |   formatDateLong, | ||||||
|  |   formatTodayString, | ||||||
|   lunarPhaseSymbol, |   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