Event search (Ctrl+F), locale/RTL handling, weekday selector workday/weekend, refactored event handling, Firefox compatibility (#3)
Major refactoring for cleanup, with various bugfixes. Weekday selector in Settings now shows workdays/weekend bars based on current locale (also used to choose default values). Weekday selector in event dialog uses the days set in settings, as expected.
This commit is contained in:
		
							
								
								
									
										10
									
								
								index.html
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								index.html
									
									
									
									
									
								
							| @@ -1,12 +1,6 @@ | |||||||
| <!doctype html> | <!doctype html> | ||||||
| <html lang="en"> | <script type="module" src="/src/main.js"></script> | ||||||
| <head> |  | ||||||
| <meta charset="utf-8"> | <meta charset="utf-8"> | ||||||
| <title>Calendar</title> | <title>Calendar</title> | ||||||
| <meta name="viewport" content="width=device-width,initial-scale=1"> | <meta name="viewport" content="width=device-width,initial-scale=1" /> | ||||||
| </head> |  | ||||||
| <body> |  | ||||||
| <div id="app"></div> | <div id="app"></div> | ||||||
| <script type="module" src="/src/main.js"></script> |  | ||||||
| </body> |  | ||||||
| </html> |  | ||||||
|   | |||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 10 KiB | 
							
								
								
									
										42
									
								
								src/App.vue
									
									
									
									
									
								
							
							
						
						
									
										42
									
								
								src/App.vue
									
									
									
									
									
								
							| @@ -1,10 +1,10 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onBeforeUnmount } from 'vue' | import { ref, onMounted, onBeforeUnmount, watch } from 'vue' | ||||||
| import CalendarView from './components/CalendarView.vue' | import CalendarView from './components/CalendarView.vue' | ||||||
| import EventDialog from './components/EventDialog.vue' |  | ||||||
| import { useCalendarStore } from './stores/CalendarStore' | import { useCalendarStore } from './stores/CalendarStore' | ||||||
|  | import { lang } from './utils/locale' | ||||||
|  | import { formatTodayString } from './utils/date' | ||||||
|  |  | ||||||
| const eventDialog = ref(null) |  | ||||||
| const calendarStore = useCalendarStore() | const calendarStore = useCalendarStore() | ||||||
|  |  | ||||||
| // Initialize holidays when app starts | // Initialize holidays when app starts | ||||||
| @@ -35,38 +35,28 @@ function handleGlobalKey(e) { | |||||||
| onMounted(() => { | onMounted(() => { | ||||||
|   calendarStore.initializeHolidaysFromConfig() |   calendarStore.initializeHolidaysFromConfig() | ||||||
|   document.addEventListener('keydown', handleGlobalKey, { passive: false }) |   document.addEventListener('keydown', handleGlobalKey, { passive: false }) | ||||||
|  |   // Set document language via shared util | ||||||
|  |   if (lang) document.documentElement.setAttribute('lang', lang) | ||||||
|  |   // Initialize title | ||||||
|  |   document.title = formatTodayString(new Date(calendarStore.now)) | ||||||
| }) | }) | ||||||
|  |  | ||||||
| onBeforeUnmount(() => { | onBeforeUnmount(() => { | ||||||
|   document.removeEventListener('keydown', handleGlobalKey) |   document.removeEventListener('keydown', handleGlobalKey) | ||||||
| }) | }) | ||||||
|  |  | ||||||
| const handleCreateEvent = (eventData) => { | // Watch today's date to update document title | ||||||
|   if (eventDialog.value) { | watch( | ||||||
|     const selectionData = { |   () => calendarStore.now, | ||||||
|       startDate: eventData.startDate, |   (val) => { | ||||||
|       dayCount: eventData.dayCount, |     document.title = formatTodayString(new Date(val)) | ||||||
|     } |   }, | ||||||
|     setTimeout(() => eventDialog.value.openCreateDialog(selectionData), 50) |   { immediate: false }, | ||||||
|   } | ) | ||||||
| } |  | ||||||
|  |  | ||||||
| const handleEditEvent = (eventClickPayload) => { |  | ||||||
|   if (eventDialog.value) { |  | ||||||
|     eventDialog.value.openEditDialog(eventClickPayload) |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const handleClearSelection = () => {} |  | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <CalendarView @create-event="handleCreateEvent" @edit-event="handleEditEvent" /> |   <CalendarView /> | ||||||
|   <EventDialog |  | ||||||
|     ref="eventDialog" |  | ||||||
|     :selection="{ startDate: null, dayCount: 0 }" |  | ||||||
|     @clear-selection="handleClearSelection" |  | ||||||
|   /> |  | ||||||
| </template> | </template> | ||||||
|  |  | ||||||
| <style scoped></style> | <style scoped></style> | ||||||
|   | |||||||
| @@ -131,7 +131,7 @@ header { | |||||||
|   overflow: visible; |   overflow: visible; | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: 0; |   top: 0; | ||||||
|   right: 0; |   inset-inline-end: 0; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
| .month-name-label > span { | .month-name-label > span { | ||||||
|   | |||||||
| @@ -79,10 +79,10 @@ const modalStyle = computed(() => { | |||||||
|   if (modalRef.value && props.modelValue) { |   if (modalRef.value && props.modelValue) { | ||||||
|     const style = { |     const style = { | ||||||
|       transform: 'none', |       transform: 'none', | ||||||
|       left: modalPosition.value.x + 'px', |       insetInlineStart: modalPosition.value.x + 'px', | ||||||
|       top: modalPosition.value.y + 'px', |       top: modalPosition.value.y + 'px', | ||||||
|       bottom: 'auto', |       bottom: 'auto', | ||||||
|       right: 'auto', |       insetInlineEnd: 'auto', | ||||||
|     } |     } | ||||||
|     if (hasMoved.value) { |     if (hasMoved.value) { | ||||||
|       style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined |       style.width = dialogWidth.value ? dialogWidth.value + 'px' : undefined | ||||||
|   | |||||||
| @@ -21,13 +21,10 @@ const props = defineProps({ | |||||||
|     ]" |     ]" | ||||||
|     :data-date="props.day.date" |     :data-date="props.day.date" | ||||||
|   > |   > | ||||||
|     <h1>{{ props.day.displayText }}</h1> |     <h1 class="day-number">{{ 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> | ||||||
|  |     <div v-if="props.day.holiday" class="holiday-info" dir="auto" :title="props.day.holiday.name"> | ||||||
|     <div v-if="props.day.holiday" class="holiday-info"> |  | ||||||
|       <span class="holiday-name" :title="props.day.holiday.name"> |  | ||||||
|       {{ props.day.holiday.name }} |       {{ props.day.holiday.name }} | ||||||
|       </span> |  | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| @@ -35,22 +32,30 @@ const props = defineProps({ | |||||||
| <style scoped> | <style scoped> | ||||||
| .cell { | .cell { | ||||||
|   position: relative; |   position: relative; | ||||||
|   border-right: 1px solid var(--border-color); |   border-inline-end: 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; | ||||||
|   display: flex; |   display: grid; | ||||||
|   flex-direction: row; |   /* 3 columns: day number, flexible space, lunar phase */ | ||||||
|   align-items: flex-start; |   grid-template-columns: min-content 1fr min-content; | ||||||
|   justify-content: flex-start; |   /* 3 rows: header, flexible filler, holiday label */ | ||||||
|  |   grid-template-rows: auto 1fr auto; | ||||||
|  |   /* Named grid areas (only ones actually used) */ | ||||||
|  |   grid-template-areas: | ||||||
|  |     'day-number . lunar-phase' | ||||||
|  |     'day-number . lunar-phase' | ||||||
|  |     'holiday-info holiday-info holiday-info'; | ||||||
|  |   /* Explicit areas mainly for clarity */ | ||||||
|  |   grid-auto-flow: row; | ||||||
|   padding: 0.25em; |   padding: 0.25em; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   height: var(--row-h); |   height: var(--row-h); | ||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   transition: background-color 0.15s ease; |   transition: background-color 0.15s ease; | ||||||
|  |   align-items: start; | ||||||
| } | } | ||||||
|  | .cell h1.day-number { | ||||||
| .cell h1 { |  | ||||||
|   margin: 0; |   margin: 0; | ||||||
|   padding: 0; |   padding: 0; | ||||||
|   min-width: 1.5em; |   min-width: 1.5em; | ||||||
| @@ -58,15 +63,16 @@ const props = defineProps({ | |||||||
|   font-weight: 700; |   font-weight: 700; | ||||||
|   color: var(--ink); |   color: var(--ink); | ||||||
|   transition: background-color 0.15s ease; |   transition: background-color 0.15s ease; | ||||||
|  |   grid-area: day-number; | ||||||
| } | } | ||||||
| .cell.weekend h1 { | .cell.weekend h1.day-number { | ||||||
|   color: var(--weekend); |   color: var(--weekend); | ||||||
| } | } | ||||||
| .cell.firstday h1 { | .cell.firstday h1.day-number { | ||||||
|   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 { | .cell.today h1.day-number { | ||||||
|   border-radius: 2em; |   border-radius: 2em; | ||||||
|   background: var(--today); |   background: var(--today); | ||||||
|   border: 0.2em solid var(--today); |   border: 0.2em solid var(--today); | ||||||
| @@ -77,16 +83,9 @@ const props = defineProps({ | |||||||
| .cell.selected { | .cell.selected { | ||||||
|   filter: hue-rotate(180deg); |   filter: hue-rotate(180deg); | ||||||
| } | } | ||||||
| .cell.selected h1 { | .cell.selected h1.day-number { | ||||||
|   color: var(--strong); |   color: var(--strong); | ||||||
| } | } | ||||||
| .lunar-phase { |  | ||||||
|   position: absolute; |  | ||||||
|   top: 0.5em; |  | ||||||
|   right: 0.2em; |  | ||||||
|   font-size: 0.8em; |  | ||||||
|   opacity: 0.7; |  | ||||||
| } |  | ||||||
| .cell.holiday { | .cell.holiday { | ||||||
|   background-image: linear-gradient( |   background-image: linear-gradient( | ||||||
|     135deg, |     135deg, | ||||||
| @@ -103,27 +102,32 @@ const props = defineProps({ | |||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
| .cell.holiday h1 { | .cell.holiday h1.day-number { | ||||||
|   /* Slight emphasis without forcing a specific hue */ |   /* Slight emphasis without forcing a specific hue */ | ||||||
|   color: var(--holiday); |   color: var(--holiday); | ||||||
|   text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4); |   text-shadow: 0 0 0.3em rgba(255, 255, 255, 0.4); | ||||||
| } | } | ||||||
| .holiday-info { | .lunar-phase { | ||||||
|   position: absolute; |   grid-area: lunar-phase; | ||||||
|   bottom: 0.1em; |   align-self: start; | ||||||
|   left: 0.1em; |   justify-self: end; | ||||||
|   right: 0.1em; |   margin-top: 0.5em; | ||||||
|   line-height: 1; |   margin-inline-end: 0.2em; | ||||||
|   overflow: hidden; |   font-size: 0.8em; | ||||||
|   font-size: clamp(1.2vw, 0.6em, 1em); |   opacity: 0.7; | ||||||
| } | } | ||||||
|  |  | ||||||
| .holiday-name { | .holiday-info { | ||||||
|   display: block; |   grid-area: holiday-info; | ||||||
|   color: var(--holiday-label); |   align-self: end; | ||||||
|   padding: 0.15em 0.35em 0.15em 0.25em; |   overflow: hidden; | ||||||
|   text-overflow: ellipsis; |   text-overflow: ellipsis; | ||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
|   overflow: hidden; |   color: var(--holiday-label); | ||||||
|  |   font-size: clamp(1.2vw, 0.6em, 1em); | ||||||
|  |   line-height: 1; | ||||||
|  |   padding-inline: 0.15em; | ||||||
|  |   padding-block-end: 0.05em; | ||||||
|  |   pointer-events: auto; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -78,8 +78,7 @@ function changeYear(y) { | |||||||
|  |  | ||||||
| const weekdayNames = computed(() => { | const weekdayNames = computed(() => { | ||||||
|   // Reorder names & weekend flags |   // Reorder names & weekend flags | ||||||
|   const mondayFirstNames = getLocalizedWeekdayNames() |   const sundayFirstNames = getLocalizedWeekdayNames() | ||||||
|   const sundayFirstNames = [mondayFirstNames[6], ...mondayFirstNames.slice(0, 6)] |  | ||||||
|   const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day) |   const reorderedNames = reorderByFirstDay(sundayFirstNames, calendarStore.config.first_day) | ||||||
|   const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day) |   const reorderedWeekend = reorderByFirstDay(calendarStore.weekend, calendarStore.config.first_day) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| <script setup> | <script setup> | ||||||
| import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' | import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } 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' | ||||||
| @@ -13,9 +13,21 @@ import { daysInclusive, addDaysStr, MIN_YEAR, MAX_YEAR } from '@/utils/date' | |||||||
| import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date' | import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date' | ||||||
| import { addDays, differenceInWeeks } from 'date-fns' | import { addDays, differenceInWeeks } from 'date-fns' | ||||||
| import { createVirtualWeekManager } from '@/plugins/virtualWeeks' | import { createVirtualWeekManager } from '@/plugins/virtualWeeks' | ||||||
|  | import { rtl } from '@/utils/locale' | ||||||
|  | import EventDialog from '@/components/EventDialog.vue' | ||||||
|  |  | ||||||
| const calendarStore = useCalendarStore() | const calendarStore = useCalendarStore() | ||||||
| const emit = defineEmits(['create-event', 'edit-event']) | defineEmits([]) // previously emitted create/edit events externally | ||||||
|  | import { shallowRef } from 'vue' | ||||||
|  | const eventDialogRef = shallowRef(null) | ||||||
|  | function openCreateEventDialog(eventData) { | ||||||
|  |   if (!eventDialogRef.value) return | ||||||
|  |   const selectionData = { startDate: eventData.startDate, dayCount: eventData.dayCount } | ||||||
|  |   setTimeout(() => eventDialogRef.value?.openCreateDialog(selectionData), 30) | ||||||
|  | } | ||||||
|  | function openEditEventDialog(eventClickPayload) { | ||||||
|  |   eventDialogRef.value?.openEditDialog(eventClickPayload) | ||||||
|  | } | ||||||
| const viewport = ref(null) | const viewport = ref(null) | ||||||
| const viewportHeight = ref(600) | const viewportHeight = ref(600) | ||||||
| const rowHeight = ref(64) | const rowHeight = ref(64) | ||||||
| @@ -87,7 +99,7 @@ const vwm = createVirtualWeekManager({ | |||||||
|   contentHeight, |   contentHeight, | ||||||
| }) | }) | ||||||
| const visibleWeeks = vwm.visibleWeeks | const visibleWeeks = vwm.visibleWeeks | ||||||
| const { scheduleWindowUpdate, resetWeeks, refreshEvents } = vwm | const { scheduleWindowUpdate, resetWeeks, refreshEvents, refreshHolidays } = vwm | ||||||
|  |  | ||||||
| // Scroll managers (after scheduleWindowUpdate available) | // Scroll managers (after scheduleWindowUpdate available) | ||||||
| const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate }) | const scrollManager = createScrollManager({ viewport, scheduleRebuild: scheduleWindowUpdate }) | ||||||
| @@ -98,8 +110,7 @@ const weekColumnScrollManager = createWeekColumnScrollManager({ | |||||||
|   contentHeight, |   contentHeight, | ||||||
|   setScrollTop, |   setScrollTop, | ||||||
| }) | }) | ||||||
| const { isWeekColDragging, handleWeekColMouseDown, handlePointerLockChange } = | const { handleWeekColMouseDown, handlePointerLockChange } = weekColumnScrollManager | ||||||
|   weekColumnScrollManager |  | ||||||
| const monthScrollManager = createMonthScrollManager({ | const monthScrollManager = createMonthScrollManager({ | ||||||
|   viewport, |   viewport, | ||||||
|   viewportHeight, |   viewportHeight, | ||||||
| @@ -160,6 +171,25 @@ function clearSelection() { | |||||||
|   selection.value = { startDate: null, dayCount: 0 } |   selection.value = { startDate: null, dayCount: 0 } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // React to holiday config changes: rebuild or refresh holidays | ||||||
|  | watch( | ||||||
|  |   () => [ | ||||||
|  |     calendarStore.config.holidays.enabled, | ||||||
|  |     calendarStore.config.holidays.country, | ||||||
|  |     calendarStore.config.holidays.state, | ||||||
|  |     calendarStore.config.holidays.region, | ||||||
|  |   ], | ||||||
|  |   (_newVals, _oldVals) => { | ||||||
|  |     // If weeks already built, just refresh holiday info | ||||||
|  |     if (visibleWeeks.value.length) { | ||||||
|  |       refreshHolidays('config-change') | ||||||
|  |     } else { | ||||||
|  |       resetWeeks('holiday-config-change') | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { deep: false }, | ||||||
|  | ) | ||||||
|  |  | ||||||
| function startDrag(dateStr) { | function startDrag(dateStr) { | ||||||
|   dateStr = normalizeDate(dateStr) |   dateStr = normalizeDate(dateStr) | ||||||
|   if (calendarStore.config.select_days === 0) return |   if (calendarStore.config.select_days === 0) return | ||||||
| @@ -188,7 +218,7 @@ function finalizeDragAndCreate() { | |||||||
|   const eventData = createEventFromSelection() |   const eventData = createEventFromSelection() | ||||||
|   if (eventData) { |   if (eventData) { | ||||||
|     clearSelection() |     clearSelection() | ||||||
|     emit('create-event', eventData) |     openCreateEventDialog(eventData) | ||||||
|   } |   } | ||||||
|   removeGlobalTouchListeners() |   removeGlobalTouchListeners() | ||||||
| } | } | ||||||
| @@ -294,15 +324,6 @@ function calculateSelection(anchorStr, otherStr) { | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| // ---------------- Week label column drag scrolling ---------------- |  | ||||||
| function getWeekLabelRect() { |  | ||||||
|   // Prefer header year label width as stable reference |  | ||||||
|   const headerYear = document.querySelector('.calendar-header .year-label') |  | ||||||
|   if (headerYear) return headerYear.getBoundingClientRect() |  | ||||||
|   const weekLabel = viewport.value?.querySelector('.week-row .week-label') |  | ||||||
|   return weekLabel ? weekLabel.getBoundingClientRect() : null |  | ||||||
| } |  | ||||||
|  |  | ||||||
| onMounted(() => { | onMounted(() => { | ||||||
|   computeRowHeight() |   computeRowHeight() | ||||||
|   calendarStore.updateCurrentDate() |   calendarStore.updateCurrentDate() | ||||||
| @@ -363,7 +384,7 @@ const handleDayMouseUp = (d) => { | |||||||
|   const ev = createEventFromSelection() |   const ev = createEventFromSelection() | ||||||
|   if (ev) { |   if (ev) { | ||||||
|     clearSelection() |     clearSelection() | ||||||
|     emit('create-event', ev) |     openCreateEventDialog(ev) | ||||||
|   } |   } | ||||||
| } | } | ||||||
| const handleDayTouchStart = (d) => { | const handleDayTouchStart = (d) => { | ||||||
| @@ -373,10 +394,156 @@ const handleDayTouchStart = (d) => { | |||||||
| } | } | ||||||
|  |  | ||||||
| const handleEventClick = (payload) => { | const handleEventClick = (payload) => { | ||||||
|   emit('edit-event', payload) |   openEditEventDialog(payload) | ||||||
| } | } | ||||||
|  |  | ||||||
| // header year change delegated to manager | // ------------------------------ | ||||||
|  | // Event Search (Ctrl/Cmd+F) | ||||||
|  | // ------------------------------ | ||||||
|  | const searchOpen = ref(false) | ||||||
|  | const searchQuery = ref('') | ||||||
|  | const searchResults = ref([]) // [{ id, title, startDate }] | ||||||
|  | const searchIndex = ref(0) | ||||||
|  | const searchInputRef = ref(null) | ||||||
|  |  | ||||||
|  | function isEditableElement(el) { | ||||||
|  |   if (!el) return false | ||||||
|  |   const tag = el.tagName | ||||||
|  |   if (tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable) return true | ||||||
|  |   return false | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function buildSearchResults() { | ||||||
|  |   const q = searchQuery.value.trim().toLowerCase() | ||||||
|  |   if (!q) { | ||||||
|  |     searchResults.value = [] | ||||||
|  |     searchIndex.value = 0 | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |   const out = [] | ||||||
|  |   for (const ev of calendarStore.events.values()) { | ||||||
|  |     const title = (ev.title || '').trim() | ||||||
|  |     if (!title) continue | ||||||
|  |     if (title.toLowerCase().includes(q)) { | ||||||
|  |       out.push({ id: ev.id, title: title, startDate: ev.startDate }) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   out.sort((a, b) => (a.startDate < b.startDate ? -1 : a.startDate > b.startDate ? 1 : 0)) | ||||||
|  |   searchResults.value = out | ||||||
|  |   if (searchIndex.value >= out.length) searchIndex.value = 0 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | watch(searchQuery, buildSearchResults) | ||||||
|  | watch( | ||||||
|  |   () => calendarStore.eventsMutation, | ||||||
|  |   () => { | ||||||
|  |     if (searchOpen.value && searchQuery.value.trim()) buildSearchResults() | ||||||
|  |   }, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | function openSearch(prefill = '') { | ||||||
|  |   searchOpen.value = true | ||||||
|  |   if (prefill) searchQuery.value = prefill | ||||||
|  |   nextTick(() => { | ||||||
|  |     if (searchInputRef.value) { | ||||||
|  |       searchInputRef.value.focus() | ||||||
|  |       searchInputRef.value.select() | ||||||
|  |     } | ||||||
|  |   }) | ||||||
|  |   buildSearchResults() | ||||||
|  | } | ||||||
|  | function closeSearch() { | ||||||
|  |   searchOpen.value = false | ||||||
|  | } | ||||||
|  | function navigateSearch(delta) { | ||||||
|  |   const n = searchResults.value.length | ||||||
|  |   if (!n) return | ||||||
|  |   searchIndex.value = (searchIndex.value + delta + n) % n | ||||||
|  |   scrollToCurrentResult() | ||||||
|  | } | ||||||
|  | function scrollToCurrentResult() { | ||||||
|  |   const cur = searchResults.value[searchIndex.value] | ||||||
|  |   if (!cur) return | ||||||
|  |   // Scroll so week containing event is near top (offset 2 weeks for context) | ||||||
|  |   try { | ||||||
|  |     const dateObj = fromLocalString(cur.startDate, DEFAULT_TZ) | ||||||
|  |     const weekIndex = getWeekIndex(dateObj) | ||||||
|  |     const offsetWeeks = 2 | ||||||
|  |     const targetScrollWeek = Math.max(minVirtualWeek.value, weekIndex - offsetWeeks) | ||||||
|  |     const newScrollTop = (targetScrollWeek - minVirtualWeek.value) * rowHeight.value | ||||||
|  |     setScrollTop(newScrollTop, 'search-jump') | ||||||
|  |     scheduleWindowUpdate('search-jump') | ||||||
|  |   } catch {} | ||||||
|  | } | ||||||
|  | function activateCurrentResult() { | ||||||
|  |   scrollToCurrentResult() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function handleGlobalFind(e) { | ||||||
|  |   if (!(e.ctrlKey || e.metaKey)) return | ||||||
|  |   const k = e.key | ||||||
|  |   if (k === 'f' || k === 'F') { | ||||||
|  |     if (isEditableElement(e.target)) return | ||||||
|  |     e.preventDefault() | ||||||
|  |     if (!searchOpen.value) openSearch('') | ||||||
|  |     else { | ||||||
|  |       // If already open, select input text for quick overwrite | ||||||
|  |       nextTick(() => { | ||||||
|  |         if (searchInputRef.value) { | ||||||
|  |           searchInputRef.value.focus() | ||||||
|  |           searchInputRef.value.select() | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   // While open: Enter confirms current selection & closes dialog | ||||||
|  |   if (searchOpen.value && (k === 'Enter' || k === 'Return')) { | ||||||
|  |     e.preventDefault() | ||||||
|  |     activateCurrentResult() | ||||||
|  |     closeSearch() | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function handleSearchKeydown(e) { | ||||||
|  |   if (!searchOpen.value) return | ||||||
|  |   if (e.key === 'Escape') { | ||||||
|  |     e.preventDefault() | ||||||
|  |     closeSearch() | ||||||
|  |   } else if (e.key === 'ArrowDown') { | ||||||
|  |     e.preventDefault() | ||||||
|  |     navigateSearch(1) | ||||||
|  |   } else if (e.key === 'ArrowUp') { | ||||||
|  |     e.preventDefault() | ||||||
|  |     navigateSearch(-1) | ||||||
|  |   } else if (e.key === 'Enter') { | ||||||
|  |     // Enter inside input: activate current and close | ||||||
|  |     e.preventDefault() | ||||||
|  |     activateCurrentResult() | ||||||
|  |     closeSearch() | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   document.addEventListener('keydown', handleGlobalFind, { passive: false }) | ||||||
|  | }) | ||||||
|  | onBeforeUnmount(() => { | ||||||
|  |   document.removeEventListener('keydown', handleGlobalFind) | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | // Ensure focus when (re)opening via reactive watch (catches programmatic toggles too) | ||||||
|  | watch( | ||||||
|  |   () => searchOpen.value, | ||||||
|  |   (v) => { | ||||||
|  |     if (v) { | ||||||
|  |       nextTick(() => { | ||||||
|  |         if (searchInputRef.value) { | ||||||
|  |           searchInputRef.value.focus() | ||||||
|  |           searchInputRef.value.select() | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | ) | ||||||
|  |  | ||||||
| // Heuristic: rotate month label (180deg) only for predominantly Latin text. | // Heuristic: rotate month label (180deg) only for predominantly Latin text. | ||||||
| // We explicitly avoid locale detection; rely solely on characters present. | // We explicitly avoid locale detection; rely solely on characters present. | ||||||
| @@ -402,7 +569,7 @@ watch( | |||||||
|   }, |   }, | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Watch lightweight mutation counter only (not deep events map) and rebuild lazily | // Event changes | ||||||
| watch( | watch( | ||||||
|   () => calendarStore.events, |   () => calendarStore.events, | ||||||
|   () => { |   () => { | ||||||
| @@ -426,7 +593,7 @@ window.addEventListener('resize', () => { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div class="calendar-view-root"> |   <div class="calendar-view-root" :dir="rtl && 'rtl'"> | ||||||
|     <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> |     <div ref="rowProbe" class="row-height-probe" aria-hidden="true"></div> | ||||||
|     <div class="wrap"> |     <div class="wrap"> | ||||||
|       <HeaderControls @go-to-today="goToToday" /> |       <HeaderControls @go-to-today="goToToday" /> | ||||||
| @@ -438,8 +605,6 @@ window.addEventListener('resize', () => { | |||||||
|       /> |       /> | ||||||
|       <div class="calendar-container"> |       <div class="calendar-container"> | ||||||
|         <div class="calendar-viewport" ref="viewport"> |         <div class="calendar-viewport" ref="viewport"> | ||||||
|           <!-- Main calendar content (weeks and days) --> |  | ||||||
|           <div class="main-calendar-area"> |  | ||||||
|           <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> |           <div class="calendar-content" :style="{ height: contentHeight + 'px' }"> | ||||||
|             <CalendarWeek |             <CalendarWeek | ||||||
|               v-for="week in visibleWeeks" |               v-for="week in visibleWeeks" | ||||||
| @@ -454,11 +619,8 @@ window.addEventListener('resize', () => { | |||||||
|               @event-click="handleEventClick" |               @event-click="handleEventClick" | ||||||
|             /> |             /> | ||||||
|           </div> |           </div> | ||||||
|           </div> |           <div class="month-column-area" :style="{ height: contentHeight + 'px' }"> | ||||||
|           <!-- Month column area --> |             <div class="month-labels-container" :style="{ height: '100%' }"> | ||||||
|           <div class="month-column-area"> |  | ||||||
|             <!-- Month labels --> |  | ||||||
|             <div class="month-labels-container" :style="{ height: contentHeight + 'px' }"> |  | ||||||
|               <template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> |               <template v-for="monthWeek in visibleWeeks" :key="monthWeek.virtualWeek + '-month'"> | ||||||
|                 <div |                 <div | ||||||
|                   v-if="monthWeek && monthWeek.monthLabel" |                   v-if="monthWeek && monthWeek.monthLabel" | ||||||
| @@ -481,6 +643,34 @@ window.addEventListener('resize', () => { | |||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  |       <EventDialog ref="eventDialogRef" :selection="{ startDate: null, dayCount: 0 }" /> | ||||||
|  |       <!-- Event Search Overlay --> | ||||||
|  |       <div v-if="searchOpen" class="event-search" @keydown.capture="handleSearchKeydown"> | ||||||
|  |         <div class="search-row"> | ||||||
|  |           <input | ||||||
|  |             ref="searchInputRef" | ||||||
|  |             v-model="searchQuery" | ||||||
|  |             type="text" | ||||||
|  |             placeholder="Search events..." | ||||||
|  |             aria-label="Search events" | ||||||
|  |             autofocus | ||||||
|  |           /> | ||||||
|  |           <button type="button" @click="closeSearch" title="Close (Esc)">✕</button> | ||||||
|  |         </div> | ||||||
|  |         <ul class="results" v-if="searchResults.length"> | ||||||
|  |           <li | ||||||
|  |             v-for="(r, i) in searchResults" | ||||||
|  |             :key="r.id" | ||||||
|  |             :class="{ active: i === searchIndex }" | ||||||
|  |             @click="((searchIndex = i), activateCurrentResult(), closeSearch())" | ||||||
|  |           > | ||||||
|  |             <span class="title">{{ r.title }}</span> | ||||||
|  |             <span class="date">{{ r.startDate }}</span> | ||||||
|  |           </li> | ||||||
|  |         </ul> | ||||||
|  |         <div v-else class="no-results">{{ searchQuery ? 'No matches' : 'Type to search' }}</div> | ||||||
|  |         <div class="hint">Enter to go, Esc to close, ↑/↓ to browse</div> | ||||||
|  |       </div> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| @@ -529,11 +719,6 @@ header h1 { | |||||||
|   grid-template-columns: 1fr var(--month-w); |   grid-template-columns: 1fr var(--month-w); | ||||||
| } | } | ||||||
|  |  | ||||||
| .main-calendar-area { |  | ||||||
|   position: relative; |  | ||||||
|   overflow: hidden; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .calendar-content { | .calendar-content { | ||||||
|   position: relative; |   position: relative; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| @@ -552,7 +737,7 @@ header h1 { | |||||||
|  |  | ||||||
| .month-label { | .month-label { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   left: 0; |   inset-inline-start: 0; | ||||||
|   width: 100%; |   width: 100%; | ||||||
|   background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2)); |   background-image: linear-gradient(to bottom, rgba(186, 186, 186, 0.3), rgba(186, 186, 186, 0.2)); | ||||||
|   font-size: 2em; |   font-size: 2em; | ||||||
| @@ -587,4 +772,93 @@ header h1 { | |||||||
|   height: var(--row-h); |   height: var(--row-h); | ||||||
|   pointer-events: none; |   pointer-events: none; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /* Search overlay */ | ||||||
|  | .event-search { | ||||||
|  |   position: fixed; | ||||||
|  |   top: 0.75rem; | ||||||
|  |   inset-inline-end: 0.75rem; | ||||||
|  |   z-index: 1200; | ||||||
|  |   background: color-mix(in srgb, var(--panel) 90%, transparent); | ||||||
|  |   backdrop-filter: blur(0.75em); | ||||||
|  |   -webkit-backdrop-filter: blur(0.75em); | ||||||
|  |   color: var(--ink); | ||||||
|  |   padding: 0.75rem 0.75rem 0.6rem 0.75rem; | ||||||
|  |   border-radius: 0.6rem; | ||||||
|  |   width: min(28rem, 80vw); | ||||||
|  |   box-shadow: 0 0.5em 1.25em rgba(0, 0, 0, 0.35); | ||||||
|  |   border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent); | ||||||
|  |   font-size: 0.9rem; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   gap: 0.4rem; | ||||||
|  | } | ||||||
|  | .event-search .search-row { | ||||||
|  |   display: flex; | ||||||
|  |   gap: 0.4rem; | ||||||
|  | } | ||||||
|  | .event-search input[type='text'] { | ||||||
|  |   flex: 1; | ||||||
|  |   padding: 0.45rem 0.6rem; | ||||||
|  |   border-radius: 0.4rem; | ||||||
|  |   border: 1px solid color-mix(in srgb, var(--muted) 40%, transparent); | ||||||
|  |   background: color-mix(in srgb, var(--panel) 85%, transparent); | ||||||
|  |   color: inherit; | ||||||
|  | } | ||||||
|  | .event-search button { | ||||||
|  |   background: color-mix(in srgb, var(--accent, #4b7) 70%, transparent); | ||||||
|  |   color: var(--ink, #111); | ||||||
|  |   border: 0; | ||||||
|  |   border-radius: 0.4rem; | ||||||
|  |   padding: 0.45rem 0.6rem; | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  | .event-search button:disabled { | ||||||
|  |   opacity: 0.4; | ||||||
|  |   cursor: default; | ||||||
|  | } | ||||||
|  | .event-search .results { | ||||||
|  |   list-style: none; | ||||||
|  |   margin: 0; | ||||||
|  |   padding: 0; | ||||||
|  |   max-height: 14rem; | ||||||
|  |   overflow: auto; | ||||||
|  |   border: 1px solid color-mix(in srgb, var(--muted) 30%, transparent); | ||||||
|  |   border-radius: 0.4rem; | ||||||
|  | } | ||||||
|  | .event-search .results li { | ||||||
|  |   display: flex; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   gap: 0.75rem; | ||||||
|  |   padding: 0.4rem 0.55rem; | ||||||
|  |   cursor: pointer; | ||||||
|  |   font-size: 0.85rem; | ||||||
|  |   line-height: 1.2; | ||||||
|  | } | ||||||
|  | .event-search .results li.active { | ||||||
|  |   background: color-mix(in srgb, var(--accent, #4b7) 45%, transparent); | ||||||
|  |   color: var(--ink, #111); | ||||||
|  |   font-weight: 600; | ||||||
|  | } | ||||||
|  | .event-search .results li:hover:not(.active) { | ||||||
|  |   background: color-mix(in srgb, var(--panel) 70%, transparent); | ||||||
|  | } | ||||||
|  | .event-search .results .title { | ||||||
|  |   flex: 1; | ||||||
|  |   overflow: hidden; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  |   white-space: nowrap; | ||||||
|  | } | ||||||
|  | .event-search .results .date { | ||||||
|  |   opacity: 0.6; | ||||||
|  |   font-family: monospace; | ||||||
|  | } | ||||||
|  | .event-search .no-results { | ||||||
|  |   padding: 0.25rem 0.1rem; | ||||||
|  |   opacity: 0.7; | ||||||
|  | } | ||||||
|  | .event-search .hint { | ||||||
|  |   opacity: 0.55; | ||||||
|  |   font-size: 0.7rem; | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ import { | |||||||
|   formatDateLong, |   formatDateLong, | ||||||
|   DEFAULT_TZ, |   DEFAULT_TZ, | ||||||
| } from '@/utils/date' | } from '@/utils/date' | ||||||
|  | import { getDate as getOccurrenceDate } from '@/utils/events' | ||||||
| import { addDays, addMonths } from 'date-fns' | import { addDays, addMonths } from 'date-fns' | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
| @@ -301,48 +302,18 @@ function openEditDialog(payload) { | |||||||
|   if (!payload) return |   if (!payload) return | ||||||
|  |  | ||||||
|   const baseId = payload.id |   const baseId = payload.id | ||||||
|   let occurrenceIndex = payload.occurrenceIndex || 0 |   let n = payload.n || 0 | ||||||
|   let weekday = null |   let weekday = null | ||||||
|   let occurrenceDate = null |   let occurrenceDate = null | ||||||
|  |  | ||||||
|   const event = calendarStore.getEventById(baseId) |   const event = calendarStore.getEventById(baseId) | ||||||
|   if (!event) return |   if (!event) return | ||||||
|  |  | ||||||
|   if (event.recur) { |   if (event.recur && n >= 0) { | ||||||
|     if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) { |     const occStr = getOccurrenceDate(event, n, DEFAULT_TZ) | ||||||
|       const pattern = event.recur.weekdays || [] |     if (occStr) { | ||||||
|       const baseStart = fromLocalString(event.startDate, DEFAULT_TZ) |       occurrenceDate = fromLocalString(occStr, DEFAULT_TZ) | ||||||
|       const baseEnd = new Date(fromLocalString(event.startDate, DEFAULT_TZ)) |       weekday = occurrenceDate.getDay() | ||||||
|       baseEnd.setDate(baseEnd.getDate() + (event.days || 1) - 1) |  | ||||||
|       if (occurrenceIndex === 0) { |  | ||||||
|         occurrenceDate = baseStart |  | ||||||
|         weekday = baseStart.getDay() |  | ||||||
|       } else { |  | ||||||
|         const interval = event.recur.interval || 1 |  | ||||||
|         const WEEK_MS = 7 * 86400000 |  | ||||||
|         const baseBlockStart = getMondayOfISOWeek(baseStart) |  | ||||||
|         function isAligned(d) { |  | ||||||
|           const blk = getMondayOfISOWeek(d) |  | ||||||
|           const diff = Math.floor((blk - baseBlockStart) / WEEK_MS) |  | ||||||
|           return diff % interval === 0 |  | ||||||
|         } |  | ||||||
|         let cur = addDays(baseEnd, 1) |  | ||||||
|         let found = 0 |  | ||||||
|         let safety = 0 |  | ||||||
|         while (found < occurrenceIndex && safety < 20000) { |  | ||||||
|           if (pattern[cur.getDay()] && isAligned(cur)) { |  | ||||||
|             found++ |  | ||||||
|             if (found === occurrenceIndex) break |  | ||||||
|           } |  | ||||||
|           cur = addDays(cur, 1) |  | ||||||
|           safety++ |  | ||||||
|         } |  | ||||||
|         occurrenceDate = cur |  | ||||||
|         weekday = cur.getDay() |  | ||||||
|       } |  | ||||||
|     } else if (event.recur.freq === 'months' && occurrenceIndex >= 0) { |  | ||||||
|       const baseDate = fromLocalString(event.startDate, DEFAULT_TZ) |  | ||||||
|       occurrenceDate = addMonths(baseDate, occurrenceIndex) |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   dialogMode.value = 'edit' |   dialogMode.value = 'edit' | ||||||
| @@ -372,10 +343,10 @@ function openEditDialog(payload) { | |||||||
|   eventSaved.value = false |   eventSaved.value = false | ||||||
|  |  | ||||||
|   if (event.recur) { |   if (event.recur) { | ||||||
|     if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) { |     if (event.recur.freq === 'weeks' && n >= 0) { | ||||||
|       occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate } |       occurrenceContext.value = { baseId, occurrenceIndex: n, weekday, occurrenceDate } | ||||||
|     } else if (event.recur.freq === 'months' && occurrenceIndex > 0) { |     } else if (event.recur.freq === 'months' && n > 0) { | ||||||
|       occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate } |       occurrenceContext.value = { baseId, occurrenceIndex: n, weekday: null, occurrenceDate } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   // anchor to base event start date |   // anchor to base event start date | ||||||
| @@ -594,8 +565,10 @@ const recurrenceSummary = computed(() => { | |||||||
| <template> | <template> | ||||||
|   <BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent"> |   <BaseDialog v-model="showDialog" :anchor-el="anchorElement" @submit="saveEvent"> | ||||||
|     <template #title> |     <template #title> | ||||||
|       {{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' |       <div class="dialog-title-row"> | ||||||
|       }}<template v-if="headerDateShort"> · {{ headerDateShort }}</template> |         {{ dialogMode === 'create' ? 'Create Event' : 'Edit Event' }} | ||||||
|  |         <span> · {{ headerDateShort }}</span> | ||||||
|  |       </div> | ||||||
|     </template> |     </template> | ||||||
|     <label class="ec-field"> |     <label class="ec-field"> | ||||||
|       <input type="text" v-model="title" autocomplete="off" ref="titleInput" /> |       <input type="text" v-model="title" autocomplete="off" ref="titleInput" /> | ||||||
| @@ -620,9 +593,7 @@ const recurrenceSummary = computed(() => { | |||||||
|         </label> |         </label> | ||||||
|         <span class="recurrence-summary" v-if="recurrenceEnabled"> |         <span class="recurrence-summary" v-if="recurrenceEnabled"> | ||||||
|           {{ recurrenceSummary }} |           {{ recurrenceSummary }} | ||||||
|           <template v-if="recurrenceOccurrences > 0"> |           <span v-if="recurrenceOccurrences > 0"> until {{ formattedFinalOccurrence }} </span> | ||||||
|             until {{ formattedFinalOccurrence }}</template |  | ||||||
|           > |  | ||||||
|         </span> |         </span> | ||||||
|         <span class="recurrence-summary muted" v-else>Does not recur</span> |         <span class="recurrence-summary muted" v-else>Does not recur</span> | ||||||
|       </div> |       </div> | ||||||
| @@ -655,6 +626,7 @@ const recurrenceSummary = computed(() => { | |||||||
|             v-model="recurrenceWeekdays" |             v-model="recurrenceWeekdays" | ||||||
|             :fallback="fallbackWeekdays" |             :fallback="fallbackWeekdays" | ||||||
|             :first-day="calendarStore.config.first_day" |             :first-day="calendarStore.config.first_day" | ||||||
|  |             :weekend="calendarStore.weekend" | ||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| @@ -668,7 +640,7 @@ const recurrenceSummary = computed(() => { | |||||||
|         <template v-if="showDeleteVariants"> |         <template v-if="showDeleteVariants"> | ||||||
|           <div class="ec-delete-group"> |           <div class="ec-delete-group"> | ||||||
|             <button type="button" class="ec-btn delete-btn" @click="deleteEventOne"> |             <button type="button" class="ec-btn delete-btn" @click="deleteEventOne"> | ||||||
|               Delete {{ formattedOccurrenceShort }} |               Delete <span>{{ formattedOccurrenceShort }}</span> | ||||||
|             </button> |             </button> | ||||||
|             <button |             <button | ||||||
|               v-if="!isLastOccurrence" |               v-if="!isLastOccurrence" | ||||||
| @@ -676,7 +648,7 @@ const recurrenceSummary = computed(() => { | |||||||
|               class="ec-btn delete-btn" |               class="ec-btn delete-btn" | ||||||
|               @click="deleteEventFrom" |               @click="deleteEventFrom" | ||||||
|             > |             > | ||||||
|               Rest |               + Rest | ||||||
|             </button> |             </button> | ||||||
|             <button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button> |             <button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button> | ||||||
|           </div> |           </div> | ||||||
| @@ -943,7 +915,7 @@ const recurrenceSummary = computed(() => { | |||||||
|   border-radius: 0.4rem; |   border-radius: 0.4rem; | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   font-size: 0.9rem; |   font-size: 0.9rem; | ||||||
|   text-align: left; |   text-align: start; | ||||||
|   transition: background-color 0.15s ease; |   transition: background-color 0.15s ease; | ||||||
| } | } | ||||||
| .ec-recurrence-toggle:hover { | .ec-recurrence-toggle:hover { | ||||||
| @@ -1003,4 +975,7 @@ const recurrenceSummary = computed(() => { | |||||||
| .ec-occurrences-field .ec-field input[type='number'] { | .ec-occurrences-field .ec-field input[type='number'] { | ||||||
|   max-width: 6rem; |   max-width: 6rem; | ||||||
| } | } | ||||||
|  | span { | ||||||
|  |   unicode-bidi: isolate; | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -1,14 +1,21 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="week-overlay"> |   <div class="week-overlay" :style="{ gridColumn: '1 / -1' }" ref="weekOverlayRef"> | ||||||
|     <div |     <div | ||||||
|       v-for="span in eventSpans" |       v-for="seg in eventSegments" | ||||||
|       :key="span.id" |       :key="'seg-' + seg.startIdx + '-' + seg.endIdx" | ||||||
|  |       :class="['segment-grid', { compress: isSegmentCompressed(seg) }]" | ||||||
|  |       :style="segmentStyle(seg)" | ||||||
|  |     > | ||||||
|  |       <div | ||||||
|  |         v-for="span in seg.events" | ||||||
|  |         :key="span.id + '-' + (span.n != null ? span.n : 0)" | ||||||
|         class="event-span" |         class="event-span" | ||||||
|  |         dir="auto" | ||||||
|         :class="[`event-color-${span.colorId}`]" |         :class="[`event-color-${span.colorId}`]" | ||||||
|         :data-id="span.id" |         :data-id="span.id" | ||||||
|       :data-n="span._recurrenceIndex != null ? span._recurrenceIndex : 0" |         :data-n="span.n != null ? span.n : 0" | ||||||
|         :style="{ |         :style="{ | ||||||
|         gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, |           gridColumn: `${span.startIdxRel + 1} / ${span.endIdxRel + 2}`, | ||||||
|           gridRow: `${span.row}`, |           gridRow: `${span.row}`, | ||||||
|         }" |         }" | ||||||
|         @click="handleEventClick(span)" |         @click="handleEventClick(span)" | ||||||
| @@ -25,9 +32,10 @@ | |||||||
|         ></div> |         ></div> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  |   </div> | ||||||
| </template> | </template> | ||||||
| <script setup> | <script setup> | ||||||
| import { computed, ref } from 'vue' | import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue' | ||||||
| import { useCalendarStore } from '@/stores/CalendarStore' | import { useCalendarStore } from '@/stores/CalendarStore' | ||||||
| import { daysInclusive, addDaysStr } from '@/utils/date' | import { daysInclusive, addDaysStr } from '@/utils/date' | ||||||
|  |  | ||||||
| @@ -40,68 +48,139 @@ const store = useCalendarStore() | |||||||
| // Drag state | // Drag state | ||||||
| const dragState = ref(null) | const dragState = ref(null) | ||||||
| const justDragged = ref(false) | const justDragged = ref(false) | ||||||
|  | const weekOverlayRef = ref(null) | ||||||
|  | const segmentCompression = ref({}) // key -> boolean | ||||||
|  |  | ||||||
| // Consolidate already-provided day.events into contiguous spans (no recurrence generation) | // Build event segments: each segment is a contiguous day range with at least one bridging event between any adjacent days within it. | ||||||
| const eventSpans = computed(() => { | const eventSegments = computed(() => { | ||||||
|   const weekEvents = new Map() |   // Construct spans across the week | ||||||
|   props.week.days.forEach((day, dayIndex) => { |   const spanMap = new Map() | ||||||
|  |   props.week.days.forEach((day, di) => { | ||||||
|     day.events.forEach((ev) => { |     day.events.forEach((ev) => { | ||||||
|       const key = ev.id |       const key = ev.id + '|' + (ev.n ?? 0) | ||||||
|       if (!weekEvents.has(key)) { |       if (!spanMap.has(key)) spanMap.set(key, { ...ev, startIdx: di, endIdx: di }) | ||||||
|         weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex }) |       else spanMap.get(key).endIdx = Math.max(spanMap.get(key).endIdx, di) | ||||||
|       } else { |  | ||||||
|         const ref = weekEvents.get(key) |  | ||||||
|         ref.endIdx = Math.max(ref.endIdx, dayIndex) |  | ||||||
|       } |  | ||||||
|     }) |     }) | ||||||
|   }) |   }) | ||||||
|   const arr = Array.from(weekEvents.values()) |   const spans = Array.from(spanMap.values()) | ||||||
|   arr.sort((a, b) => { |   // Derive span start/end date strings from week day indices (removes need for per-day stored endDate) | ||||||
|     const spanA = a.endIdx - a.startIdx |   spans.forEach((sp) => { | ||||||
|     const spanB = b.endIdx - b.startIdx |     sp.startDate = props.week.days[sp.startIdx].date | ||||||
|     if (spanA !== spanB) return spanB - spanA |     sp.endDate = props.week.days[sp.endIdx].date | ||||||
|  |   }) | ||||||
|  |   // Sort so longer multi-day first, then earlier, then id for stability | ||||||
|  |   spans.sort((a, b) => { | ||||||
|  |     const la = a.endIdx - a.startIdx | ||||||
|  |     const lb = b.endIdx - b.startIdx | ||||||
|  |     if (la !== lb) return lb - la | ||||||
|     if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx |     if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx | ||||||
|     // For one-day events that are otherwise equal, sort by color (0 first) |     const ca = a.colorId != null ? a.colorId : 0 | ||||||
|     if (spanA === 0 && spanB === 0 && a.startIdx === b.startIdx) { |     const cb = b.colorId != null ? b.colorId : 0 | ||||||
|       const colorA = a.colorId || 0 |     if (ca !== cb) return ca - cb | ||||||
|       const colorB = b.colorId || 0 |  | ||||||
|       if (colorA !== colorB) return colorA - colorB |  | ||||||
|     } |  | ||||||
|     return String(a.id).localeCompare(String(b.id)) |     return String(a.id).localeCompare(String(b.id)) | ||||||
|   }) |   }) | ||||||
|   // Assign non-overlapping rows |   // Identify breaks | ||||||
|   const rowsLastEnd = [] |   const breaks = [] | ||||||
|   arr.forEach((ev) => { |   for (let d = 0; d < 6; d++) { | ||||||
|     let row = 0 |     const bridged = spans.some((sp) => sp.startIdx <= d && sp.endIdx >= d + 1) | ||||||
|     while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++ |     if (!bridged) breaks.push(d) | ||||||
|     if (row === rowsLastEnd.length) rowsLastEnd.push(-1) |   } | ||||||
|     rowsLastEnd[row] = ev.endIdx |   const rawSegments = [] | ||||||
|     ev.row = row + 1 |   let segStart = 0 | ||||||
|  |   for (const b of breaks) { | ||||||
|  |     rawSegments.push([segStart, b]) | ||||||
|  |     segStart = b + 1 | ||||||
|  |   } | ||||||
|  |   rawSegments.push([segStart, 6]) | ||||||
|  |  | ||||||
|  |   const segments = rawSegments.map(([s, e]) => { | ||||||
|  |     const evs = spans.filter((sp) => sp.startIdx >= s && sp.endIdx <= e) | ||||||
|  |     // Row packing in this segment (gap fill) | ||||||
|  |     const rows = [] // each row: intervals | ||||||
|  |     function fits(row, a, b) { | ||||||
|  |       return row.every((iv) => b < iv.start || a > iv.end) | ||||||
|  |     } | ||||||
|  |     function addInterval(row, a, b) { | ||||||
|  |       let inserted = false | ||||||
|  |       for (let i = 0; i < row.length; i++) { | ||||||
|  |         if (b < row[i].start) { | ||||||
|  |           row.splice(i, 0, { start: a, end: b }) | ||||||
|  |           inserted = true | ||||||
|  |           break | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       if (!inserted) row.push({ start: a, end: b }) | ||||||
|  |     } | ||||||
|  |     evs.forEach((ev) => { | ||||||
|  |       let placed = false | ||||||
|  |       for (let r = 0; r < rows.length; r++) { | ||||||
|  |         if (fits(rows[r], ev.startIdx, ev.endIdx)) { | ||||||
|  |           addInterval(rows[r], ev.startIdx, ev.endIdx) | ||||||
|  |           ev.row = r + 1 | ||||||
|  |           placed = true | ||||||
|  |           break | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       if (!placed) { | ||||||
|  |         rows.push([{ start: ev.startIdx, end: ev.endIdx }]) | ||||||
|  |         ev.row = rows.length | ||||||
|  |       } | ||||||
|  |       ev.startIdxRel = ev.startIdx - s | ||||||
|  |       ev.endIdxRel = ev.endIdx - s | ||||||
|     }) |     }) | ||||||
|   return arr |     return { startIdx: s, endIdx: e, events: evs, rowsCount: rows.length } | ||||||
|  |   }) | ||||||
|  |   return segments | ||||||
|  | }) | ||||||
|  |  | ||||||
|  | function segmentStyle(seg) { | ||||||
|  |   return { gridColumn: `${seg.startIdx + 1} / ${seg.endIdx + 2}` } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function segmentKey(seg) { | ||||||
|  |   return seg.startIdx + '-' + seg.endIdx | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function isSegmentCompressed(seg) { | ||||||
|  |   return !!segmentCompression.value[segmentKey(seg)] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function recomputeCompression() { | ||||||
|  |   const el = weekOverlayRef.value | ||||||
|  |   if (!el) return | ||||||
|  |   const available = el.clientHeight || 0 | ||||||
|  |   if (!available) return | ||||||
|  |   const cs = getComputedStyle(el) | ||||||
|  |   const fontSize = parseFloat(cs.fontSize) || 16 | ||||||
|  |   const baseRowPx = fontSize * 1.5 // desired row height (matches CSS 1.5em) | ||||||
|  |   const marginTop = 0 // already applied outside height | ||||||
|  |   const usable = Math.max(0, available - marginTop) | ||||||
|  |   const nextMap = {} | ||||||
|  |   for (const seg of eventSegments.value) { | ||||||
|  |     const desired = (seg.rowsCount || 1) * baseRowPx | ||||||
|  |     nextMap[segmentKey(seg)] = desired > usable | ||||||
|  |   } | ||||||
|  |   segmentCompression.value = nextMap | ||||||
|  | } | ||||||
|  |  | ||||||
|  | watch(eventSegments, () => nextTick(() => recomputeCompression())) | ||||||
|  | onMounted(() => { | ||||||
|  |   nextTick(() => recomputeCompression()) | ||||||
|  |   window.addEventListener('resize', recomputeCompression) | ||||||
|  | }) | ||||||
|  | onBeforeUnmount(() => { | ||||||
|  |   window.removeEventListener('resize', recomputeCompression) | ||||||
| }) | }) | ||||||
|  |  | ||||||
| function handleEventClick(span) { | function handleEventClick(span) { | ||||||
|   if (justDragged.value) return |   if (justDragged.value) return | ||||||
|   // Emit composite payload: base id (without virtual marker), instance id, occurrence index (data-n) |   emit('event-click', { id: span.id, n: span.n != null ? span.n : 0 }) | ||||||
|   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, |  | ||||||
|   }) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| function handleEventPointerDown(span, event) { | function handleEventPointerDown(span, event) { | ||||||
|   if (event.target.classList.contains('resize-handle')) return |   if (event.target.classList.contains('resize-handle')) return | ||||||
|   event.stopPropagation() |   event.stopPropagation() | ||||||
|   const idStr = span.id |   const baseId = span.id | ||||||
|   const hasVirtualMarker = typeof idStr === 'string' && idStr.includes('_v_') |  | ||||||
|   const baseId = hasVirtualMarker ? idStr.slice(0, idStr.lastIndexOf('_v_')) : idStr |  | ||||||
|   const isVirtual = hasVirtualMarker |  | ||||||
|   // Determine which day within the span was grabbed so we maintain relative position |  | ||||||
|   let anchorDate = span.startDate |   let anchorDate = span.startDate | ||||||
|   try { |   try { | ||||||
|     const spanDays = daysInclusive(span.startDate, span.endDate) |     const spanDays = daysInclusive(span.startDate, span.endDate) | ||||||
| @@ -116,14 +195,11 @@ function handleEventPointerDown(span, event) { | |||||||
|       if (dayIndex >= spanDays) dayIndex = spanDays - 1 |       if (dayIndex >= spanDays) dayIndex = spanDays - 1 | ||||||
|       anchorDate = addDaysStr(span.startDate, dayIndex) |       anchorDate = addDaysStr(span.startDate, dayIndex) | ||||||
|     } |     } | ||||||
|   } catch (e) { |   } catch (e) {} | ||||||
|     // Fallback to startDate if any calculation fails |  | ||||||
|   } |  | ||||||
|   startLocalDrag( |   startLocalDrag( | ||||||
|     { |     { | ||||||
|       id: baseId, |       id: baseId, | ||||||
|       originalId: span.id, |       originalId: span.id, | ||||||
|       isVirtual, |  | ||||||
|       mode: 'move', |       mode: 'move', | ||||||
|       pointerStartX: event.clientX, |       pointerStartX: event.clientX, | ||||||
|       pointerStartY: event.clientY, |       pointerStartY: event.clientY, | ||||||
| @@ -137,15 +213,11 @@ function handleEventPointerDown(span, event) { | |||||||
|  |  | ||||||
| function handleResizePointerDown(span, mode, event) { | function handleResizePointerDown(span, mode, event) { | ||||||
|   event.stopPropagation() |   event.stopPropagation() | ||||||
|   const idStr = span.id |   const baseId = 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: baseId, |       id: baseId, | ||||||
|       originalId: span.id, |       originalId: span.id, | ||||||
|       isVirtual, |  | ||||||
|       mode, |       mode, | ||||||
|       pointerStartX: event.clientX, |       pointerStartX: event.clientX, | ||||||
|       pointerStartY: event.clientY, |       pointerStartY: event.clientY, | ||||||
| @@ -167,7 +239,6 @@ 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 originalWeekday = null | ||||||
|   let originalPattern = null |   let originalPattern = null | ||||||
|   if (init.mode === 'move') { |   if (init.mode === 'move') { | ||||||
| @@ -194,13 +265,11 @@ function startLocalDrag(init, evt) { | |||||||
|     tentativeEnd: init.endDate, |     tentativeEnd: init.endDate, | ||||||
|     originalWeekday, |     originalWeekday, | ||||||
|     originalPattern, |     originalPattern, | ||||||
|     realizedId: null, // for virtual occurrence converted to real during drag |     realizedId: null, | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Begin compound history session (single snapshot after drag completes) |  | ||||||
|   store.$history?.beginCompound() |   store.$history?.beginCompound() | ||||||
|  |  | ||||||
|   // Capture pointer events globally |  | ||||||
|   if (evt.currentTarget && evt.pointerId !== undefined) { |   if (evt.currentTarget && evt.pointerId !== undefined) { | ||||||
|     try { |     try { | ||||||
|       evt.currentTarget.setPointerCapture(evt.pointerId) |       evt.currentTarget.setPointerCapture(evt.pointerId) | ||||||
| @@ -209,7 +278,6 @@ function startLocalDrag(init, evt) { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Prevent default for mouse/pen to avoid text selection. For touch we skip so the user can still scroll. |  | ||||||
|   if (!(evt.pointerType === 'touch')) { |   if (!(evt.pointerType === 'touch')) { | ||||||
|     evt.preventDefault() |     evt.preventDefault() | ||||||
|   } |   } | ||||||
| @@ -221,19 +289,15 @@ function startLocalDrag(init, evt) { | |||||||
|  |  | ||||||
| // Determine date under pointer: traverse DOM to find day cell carrying data-date attribute | // Determine date under pointer: traverse DOM to find day cell carrying data-date attribute | ||||||
| function getDateUnderPointer(x, y, el) { | function getDateUnderPointer(x, y, el) { | ||||||
|   let cur = el |   for (let cur = el; cur; cur = cur.parentElement) | ||||||
|   while (cur) { |     if (cur.dataset?.date) return { date: cur.dataset.date } | ||||||
|     if (cur.dataset && cur.dataset.date) { |   const overlayEl = weekOverlayRef.value | ||||||
|       return { date: cur.dataset.date } |   const container = overlayEl?.parentElement // .days-grid | ||||||
|  |   if (container) { | ||||||
|  |     for (const d of container.querySelectorAll('[data-date]')) { | ||||||
|  |       const { left, right, top, bottom } = d.getBoundingClientRect() | ||||||
|  |       if (y >= top && y <= bottom && x >= left && x <= right) return { date: d.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 |   return null | ||||||
| } | } | ||||||
| @@ -250,7 +314,6 @@ function onDragPointerMove(e) { | |||||||
|   const hitEl = document.elementFromPoint(e.clientX, e.clientY) |   const hitEl = document.elementFromPoint(e.clientX, e.clientY) | ||||||
|   const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl) |   const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl) | ||||||
|  |  | ||||||
|   // If we can't find a date, don't update the range but keep the drag active |  | ||||||
|   if (!hit || !hit.date) return |   if (!hit || !hit.date) return | ||||||
|  |  | ||||||
|   const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) |   const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) | ||||||
| @@ -260,26 +323,22 @@ function onDragPointerMove(e) { | |||||||
|   st.tentativeStart = ns |   st.tentativeStart = ns | ||||||
|   st.tentativeEnd = ne |   st.tentativeEnd = ne | ||||||
|   if (st.mode === 'move') { |   if (st.mode === 'move') { | ||||||
|     if (st.isVirtual) { |     if (st.n && st.n > 0) { | ||||||
|       // On first movement convert virtual occurrence into a real new event (split series) |  | ||||||
|       if (!st.realizedId) { |       if (!st.realizedId) { | ||||||
|         const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne) |         const newId = store.splitMoveVirtualOccurrence(st.id, st.startDate, ns, ne) | ||||||
|         if (newId) { |         if (newId) { | ||||||
|           st.realizedId = newId |           st.realizedId = newId | ||||||
|           st.id = newId |           st.id = newId | ||||||
|           st.isVirtual = false |           // converted to standalone event | ||||||
|         } else { |         } else { | ||||||
|           return |           return | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
|         // Subsequent moves: update range without rotating pattern automatically |  | ||||||
|         store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) |         store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) | ||||||
|       } |       } | ||||||
|     } else { |     } else { | ||||||
|       // Normal non-virtual move; rotate handled in setEventRange |  | ||||||
|       store.setEventRange(st.id, ns, ne, { mode: 'move', rotatePattern: false }) |       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) { |     if (st.originalPattern && st.originalWeekday != null) { | ||||||
|       try { |       try { | ||||||
|         const currentWeekday = new Date(ns + 'T00:00:00').getDay() |         const currentWeekday = new Date(ns + 'T00:00:00').getDay() | ||||||
| @@ -292,15 +351,9 @@ function onDragPointerMove(e) { | |||||||
|         } |         } | ||||||
|       } catch {} |       } catch {} | ||||||
|     } |     } | ||||||
|   } else if (!st.isVirtual) { |   } else if (!(st.n && st.n > 0)) { | ||||||
|     // Resizes on real events update immediately |     applyRangeDuringDrag({ id: st.id, mode: st.mode, startDate: ns, endDate: ne }, ns, ne) | ||||||
|     applyRangeDuringDrag( |   } else if (st.n && st.n > 0 && (st.mode === 'resize-left' || st.mode === 'resize-right')) { | ||||||
|       { 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) { |     if (!st.realizedId) { | ||||||
|       const initialStart = ns |       const initialStart = ns | ||||||
|       const initialEnd = ne |       const initialEnd = ne | ||||||
| @@ -308,10 +361,9 @@ function onDragPointerMove(e) { | |||||||
|       if (newId) { |       if (newId) { | ||||||
|         st.realizedId = newId |         st.realizedId = newId | ||||||
|         st.id = newId |         st.id = newId | ||||||
|         st.isVirtual = false |         // converted | ||||||
|       } else return |       } else return | ||||||
|     } |     } | ||||||
|     // Apply range change; rotate if left edge moved and weekday changed |  | ||||||
|     const rotate = st.mode === 'resize-left' |     const rotate = st.mode === 'resize-left' | ||||||
|     store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate }) |     store.setEventRange(st.id, ns, ne, { mode: st.mode, rotatePattern: rotate }) | ||||||
|   } |   } | ||||||
| @@ -321,7 +373,6 @@ function onDragPointerUp(e) { | |||||||
|   const st = dragState.value |   const st = dragState.value | ||||||
|   if (!st) return |   if (!st) return | ||||||
|  |  | ||||||
|   // Release pointer capture if it was set |  | ||||||
|   if (e.target && e.pointerId !== undefined) { |   if (e.target && e.pointerId !== undefined) { | ||||||
|     try { |     try { | ||||||
|       e.target.releasePointerCapture(e.pointerId) |       e.target.releasePointerCapture(e.pointerId) | ||||||
| @@ -341,11 +392,10 @@ function onDragPointerUp(e) { | |||||||
|  |  | ||||||
|   if (moved) { |   if (moved) { | ||||||
|     // Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare) |     // Apply final mutation if virtual (we deferred) or if non-virtual no further change (rare) | ||||||
|     if (st.isVirtual) { |     if (st.n && st.n > 0) { | ||||||
|       applyRangeDuringDrag( |       applyRangeDuringDrag( | ||||||
|         { |         { | ||||||
|           id: st.id, |           id: st.id, | ||||||
|           isVirtual: st.isVirtual, |  | ||||||
|           mode: st.mode, |           mode: st.mode, | ||||||
|           startDate: finalStart, |           startDate: finalStart, | ||||||
|           endDate: finalEnd, |           endDate: finalEnd, | ||||||
| @@ -359,7 +409,6 @@ function onDragPointerUp(e) { | |||||||
|       justDragged.value = false |       justDragged.value = false | ||||||
|     }, 120) |     }, 120) | ||||||
|   } |   } | ||||||
|   // End compound session (snapshot if changed) |  | ||||||
|   store.$history?.endCompound() |   store.$history?.endCompound() | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -388,7 +437,7 @@ function normalizeDateOrder(aStr, bStr) { | |||||||
| } | } | ||||||
|  |  | ||||||
| function applyRangeDuringDrag(st, startDate, endDate) { | function applyRangeDuringDrag(st, startDate, endDate) { | ||||||
|   if (st.isVirtual) { |   if (st.n && st.n > 0) { | ||||||
|     if (st.mode !== 'move') return // no resize for virtual occurrence |     if (st.mode !== 'move') return // no resize for virtual occurrence | ||||||
|     // Split-move: occurrence being dragged treated as first of new series |     // Split-move: occurrence being dragged treated as first of new series | ||||||
|     store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate) |     store.splitMoveVirtualOccurrence(st.id, st.startDate, startDate, endDate) | ||||||
| @@ -402,16 +451,22 @@ function applyRangeDuringDrag(st, startDate, endDate) { | |||||||
| .week-overlay { | .week-overlay { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   inset: 0; |   inset: 0; | ||||||
|   pointer-events: none; |  | ||||||
|   z-index: 15; |  | ||||||
|   display: grid; |   display: grid; | ||||||
|   /* Prevent content from expanding tracks beyond container width */ |   grid-template-columns: repeat(7, 1fr); | ||||||
|   grid-template-columns: repeat(7, minmax(0, 1fr)); |  | ||||||
|   grid-auto-rows: minmax(0, 1.5em); |  | ||||||
|  |  | ||||||
|   row-gap: 0.05em; |  | ||||||
|   margin-top: 1.8em; |   margin-top: 1.8em; | ||||||
|  |   pointer-events: none; | ||||||
|  | } | ||||||
|  | .segment-grid { | ||||||
|  |   display: grid; | ||||||
|  |   gap: 2px; | ||||||
|   align-content: start; |   align-content: start; | ||||||
|  |   pointer-events: none; | ||||||
|  |   overflow: hidden; | ||||||
|  |   grid-auto-columns: 1fr; | ||||||
|  |   grid-auto-rows: 1.5em; | ||||||
|  | } | ||||||
|  | .segment-grid.compress { | ||||||
|  |   grid-auto-rows: 1fr; | ||||||
| } | } | ||||||
|  |  | ||||||
| .event-span { | .event-span { | ||||||
| @@ -429,13 +484,8 @@ function applyRangeDuringDrag(st, startDate, endDate) { | |||||||
|   align-items: center; |   align-items: center; | ||||||
|   position: relative; |   position: relative; | ||||||
|   user-select: none; |   user-select: none; | ||||||
|   height: 100%; |  | ||||||
|   width: 100%; |  | ||||||
|   max-width: 100%; |  | ||||||
|   box-sizing: border-box; |  | ||||||
|   z-index: 1; |   z-index: 1; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   min-width: 0; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Inner title wrapper ensures proper ellipsis within flex/grid constraints */ | /* Inner title wrapper ensures proper ellipsis within flex/grid constraints */ | ||||||
| @@ -463,10 +513,10 @@ function applyRangeDuringDrag(st, startDate, endDate) { | |||||||
| } | } | ||||||
|  |  | ||||||
| .event-span .resize-handle.left { | .event-span .resize-handle.left { | ||||||
|   left: 0; |   inset-inline-start: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| .event-span .resize-handle.right { | .event-span .resize-handle.right { | ||||||
|   right: 0; |   inset-inline-end: 0; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -31,7 +31,6 @@ | |||||||
|       > |       > | ||||||
|         ⚙ |         ⚙ | ||||||
|       </button> |       </button> | ||||||
|       <!-- Settings dialog now lives here --> |  | ||||||
|       <SettingsDialog ref="settingsDialog" /> |       <SettingsDialog ref="settingsDialog" /> | ||||||
|     </div> |     </div> | ||||||
|   </Transition> |   </Transition> | ||||||
| @@ -101,12 +100,12 @@ onBeforeUnmount(() => { | |||||||
|   display: flex; |   display: flex; | ||||||
|   justify-content: end; |   justify-content: end; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   margin-right: 1.5rem; |   margin-inline-end: 2rem; | ||||||
| } | } | ||||||
| .toggle-btn { | .toggle-btn { | ||||||
|   position: fixed; |   position: fixed; | ||||||
|   top: 0; |   top: 0; | ||||||
|   right: 0; |   inset-inline-end: 0; | ||||||
|   background: transparent; |   background: transparent; | ||||||
|   border: none; |   border: none; | ||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
| @@ -157,7 +156,6 @@ onBeforeUnmount(() => { | |||||||
|   color: var(--muted); |   color: var(--muted); | ||||||
|   padding: 0; |   padding: 0; | ||||||
|   margin: 0; |   margin: 0; | ||||||
|   margin-right: 0.6rem; |  | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   font-size: 1.5rem; |   font-size: 1.5rem; | ||||||
|   line-height: 1; |   line-height: 1; | ||||||
| @@ -205,6 +203,6 @@ onBeforeUnmount(() => { | |||||||
| .today-date { | .today-date { | ||||||
|   white-space: pre-line; |   white-space: pre-line; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   margin-right: 2rem; |   margin-inline-end: 2rem; | ||||||
| } | } | ||||||
| </style> | </style> | ||||||
|   | |||||||
| @@ -181,7 +181,7 @@ defineExpose({ | |||||||
| .jogwheel-viewport { | .jogwheel-viewport { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   top: 0; |   top: 0; | ||||||
|   right: 0; |   inset-inline-end: 0; | ||||||
|   bottom: 0; |   bottom: 0; | ||||||
|   width: var(--month-w); |   width: var(--month-w); | ||||||
|   overflow-y: auto; |   overflow-y: auto; | ||||||
|   | |||||||
| @@ -3,10 +3,14 @@ import { ref, computed } from 'vue' | |||||||
| import BaseDialog from './BaseDialog.vue' | import BaseDialog from './BaseDialog.vue' | ||||||
| import { useCalendarStore } from '@/stores/CalendarStore' | import { useCalendarStore } from '@/stores/CalendarStore' | ||||||
| import WeekdaySelector from './WeekdaySelector.vue' | import WeekdaySelector from './WeekdaySelector.vue' | ||||||
|  | import { getLocalizedWeekdayNamesLong } from '@/utils/date' | ||||||
|  |  | ||||||
| const show = ref(false) | const show = ref(false) | ||||||
| const calendarStore = useCalendarStore() | const calendarStore = useCalendarStore() | ||||||
|  |  | ||||||
|  | // Localized weekday names (now Sunday-first from util) for select 0=Sunday ..6=Saturday | ||||||
|  | const weekdayNames = getLocalizedWeekdayNamesLong() | ||||||
|  |  | ||||||
| // Reactive bindings to store | // Reactive bindings to store | ||||||
| const firstDay = computed({ | const firstDay = computed({ | ||||||
|   get: () => calendarStore.config.first_day, |   get: () => calendarStore.config.first_day, | ||||||
| @@ -159,19 +163,21 @@ defineExpose({ open }) | |||||||
|     v-model="show" |     v-model="show" | ||||||
|     title="Settings" |     title="Settings" | ||||||
|     class="settings-modal" |     class="settings-modal" | ||||||
|     :style="{ top: '4.5rem', right: '2rem', bottom: 'auto', left: 'auto', transform: 'none' }" |     :style="{ | ||||||
|  |       top: '4.5rem', | ||||||
|  |       insetInlineEnd: '2rem', | ||||||
|  |       bottom: 'auto', | ||||||
|  |       insetInlineStart: 'auto', | ||||||
|  |       transform: 'none', | ||||||
|  |     }" | ||||||
|   > |   > | ||||||
|     <div class="setting-group"> |     <div class="setting-group"> | ||||||
|       <label class="ec-field"> |       <label class="ec-field"> | ||||||
|         <span>First day of week</span> |         <span>First day of week</span> | ||||||
|         <select v-model.number="firstDay"> |         <select v-model.number="firstDay"> | ||||||
|           <option :value="0">Sunday</option> |           <option v-for="(name, idx) in weekdayNames" :key="idx" :value="idx"> | ||||||
|           <option :value="1">Monday</option> |             {{ name.charAt(0).toUpperCase() + name.slice(1) }} | ||||||
|           <option :value="2">Tuesday</option> |           </option> | ||||||
|           <option :value="3">Wednesday</option> |  | ||||||
|           <option :value="4">Thursday</option> |  | ||||||
|           <option :value="5">Friday</option> |  | ||||||
|           <option :value="6">Saturday</option> |  | ||||||
|         </select> |         </select> | ||||||
|       </label> |       </label> | ||||||
|       <div class="weekend-select ec-field"> |       <div class="weekend-select ec-field"> | ||||||
| @@ -242,9 +248,9 @@ defineExpose({ open }) | |||||||
| .holiday-settings { | .holiday-settings { | ||||||
|   display: grid; |   display: grid; | ||||||
|   gap: 0.75rem; |   gap: 0.75rem; | ||||||
|   margin-left: 1rem; |   margin-inline-start: 1rem; | ||||||
|   padding-left: 1rem; |   padding-inline-start: 1rem; | ||||||
|   border-left: 2px solid var(--border-color); |   border-inline-start: 2px solid var(--border-color); | ||||||
| } | } | ||||||
| select { | select { | ||||||
|   border: 1px solid var(--muted); |   border: 1px solid var(--muted); | ||||||
| @@ -269,7 +275,7 @@ select { | |||||||
|   flex: 0 0 auto; |   flex: 0 0 auto; | ||||||
|   min-width: 120px; |   min-width: 120px; | ||||||
| } | } | ||||||
| /* WeekdaySelector display tweaks */ |  | ||||||
| .footer-row { | .footer-row { | ||||||
|   display: flex; |   display: flex; | ||||||
|   justify-content: flex-end; |   justify-content: flex-end; | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ | |||||||
|       @pointerenter="onDragOver(di)" |       @pointerenter="onDragOver(di)" | ||||||
|       @pointerup="onPointerUp" |       @pointerup="onPointerUp" | ||||||
|     > |     > | ||||||
|       {{ d.slice(0, 3) }} |       {{ d }} | ||||||
|     </button> |     </button> | ||||||
|     <button |     <button | ||||||
|       v-for="g in barGroups" |       v-for="g in barGroups" | ||||||
| @@ -60,8 +60,8 @@ const props = defineProps({ | |||||||
|  |  | ||||||
| // Initialize internal from external if it has any true; else keep empty (fallback handled on emit) | // Initialize internal from external if it has any true; else keep empty (fallback handled on emit) | ||||||
| if (model.value?.some?.(Boolean)) internal.value = [...model.value] | if (model.value?.some?.(Boolean)) internal.value = [...model.value] | ||||||
| const labelsMondayFirst = getLocalizedWeekdayNames() | // getLocalizedWeekdayNames now returns Sunday-first already | ||||||
| const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)] | const labels = getLocalizedWeekdayNames() | ||||||
| const anySelected = computed(() => internal.value.some(Boolean)) | const anySelected = computed(() => internal.value.some(Boolean)) | ||||||
| const localeFirst = getLocaleFirstDay() | const localeFirst = getLocaleFirstDay() | ||||||
| const localeWeekend = getLocaleWeekendDays() | const localeWeekend = getLocaleWeekendDays() | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { ref } from 'vue' | import { ref } from 'vue' | ||||||
| import { addDays, differenceInWeeks } from 'date-fns' | import { addDays, differenceInWeeks, isBefore, isAfter } from 'date-fns' | ||||||
| import { | import { | ||||||
|   toLocalString, |   toLocalString, | ||||||
|   fromLocalString, |   fromLocalString, | ||||||
| @@ -11,9 +11,8 @@ import { | |||||||
|   monthAbbr, |   monthAbbr, | ||||||
|   lunarPhaseSymbol, |   lunarPhaseSymbol, | ||||||
|   MAX_YEAR, |   MAX_YEAR, | ||||||
|   getOccurrenceIndex, |  | ||||||
|   getVirtualOccurrenceEndDate, |  | ||||||
| } from '@/utils/date' | } from '@/utils/date' | ||||||
|  | import { buildDayEvents } from '@/utils/events' | ||||||
| import { getHolidayForDate } from '@/utils/holidays' | import { getHolidayForDate } from '@/utils/holidays' | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -54,77 +53,16 @@ export function createVirtualWeekManager({ | |||||||
|  |  | ||||||
|   function createWeek(virtualWeek) { |   function createWeek(virtualWeek) { | ||||||
|     const firstDay = getFirstDayForVirtualWeek(virtualWeek) |     const firstDay = getFirstDayForVirtualWeek(virtualWeek) | ||||||
|     const isoAnchor = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7) |     const thu = addDays(firstDay, (4 - firstDay.getDay() + 7) % 7) | ||||||
|     const weekNumber = getISOWeek(isoAnchor) |     const weekNumber = getISOWeek(thu) | ||||||
|     const days = [] |     const days = [] | ||||||
|     let cur = new Date(firstDay) |     let cur = new Date(firstDay) | ||||||
|     let hasFirst = false |     let hasFirst = false | ||||||
|     let monthToLabel = null |     let monthToLabel = null | ||||||
|     let labelYear = 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++) { |     for (let i = 0; i < 7; i++) { | ||||||
|       const dateStr = toLocalString(cur, DEFAULT_TZ) |       const dateStr = toLocalString(cur, DEFAULT_TZ) | ||||||
|       const dayEvents = collectEventsForDate(dateStr, fromLocalString(dateStr, DEFAULT_TZ)) |       const events = buildDayEvents(calendarStore.events.values(), dateStr, DEFAULT_TZ) | ||||||
|       const dow = cur.getDay() |       const dow = cur.getDay() | ||||||
|       const isFirst = cur.getDate() === 1 |       const isFirst = cur.getDate() === 1 | ||||||
|       if (isFirst) { |       if (isFirst) { | ||||||
| @@ -133,10 +71,11 @@ export function createVirtualWeekManager({ | |||||||
|         labelYear = cur.getFullYear() |         labelYear = cur.getFullYear() | ||||||
|       } |       } | ||||||
|       let displayText = String(cur.getDate()) |       let displayText = String(cur.getDate()) | ||||||
|       if (isFirst) { |       if (isFirst) | ||||||
|         if (cur.getMonth() === 0) displayText = cur.getFullYear() |         displayText = | ||||||
|         else displayText = monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase() |           cur.getMonth() === 0 | ||||||
|       } |             ? cur.getFullYear() | ||||||
|  |             : monthAbbr[cur.getMonth()].slice(0, 3).toUpperCase() | ||||||
|       let holiday = null |       let holiday = null | ||||||
|       if (calendarStore.config.holidays.enabled) { |       if (calendarStore.config.holidays.enabled) { | ||||||
|         calendarStore._ensureHolidaysInitialized?.() |         calendarStore._ensureHolidaysInitialized?.() | ||||||
| @@ -153,18 +92,33 @@ export function createVirtualWeekManager({ | |||||||
|         lunarPhase: lunarPhaseSymbol(cur), |         lunarPhase: lunarPhaseSymbol(cur), | ||||||
|         holiday, |         holiday, | ||||||
|         isHoliday: holiday !== null, |         isHoliday: holiday !== null, | ||||||
|         isSelected: |         isSelected: isDateSelected(dateStr), | ||||||
|           selection.value.startDate && |         events, | ||||||
|           selection.value.dayCount > 0 && |  | ||||||
|           dateStr >= selection.value.startDate && |  | ||||||
|           dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1), |  | ||||||
|         events: dayEvents, |  | ||||||
|       }) |       }) | ||||||
|       cur = addDays(cur, 1) |       cur = addDays(cur, 1) | ||||||
|     } |     } | ||||||
|     let monthLabel = null |     const monthLabel = buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek }) | ||||||
|     if (hasFirst && monthToLabel !== null) { |     return { | ||||||
|       if (labelYear && labelYear <= MAX_YEAR) { |       virtualWeek, | ||||||
|  |       weekNumber: pad(weekNumber), | ||||||
|  |       days, | ||||||
|  |       monthLabel, | ||||||
|  |       top: (virtualWeek - minVirtualWeek.value) * rowHeight.value, | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function isDateSelected(dateStr) { | ||||||
|  |     if (!selection.value.startDate || selection.value.dayCount <= 0) return false | ||||||
|  |     const startDateObj = fromLocalString(selection.value.startDate, DEFAULT_TZ) | ||||||
|  |     const endDateStr = addDaysStr(selection.value.startDate, selection.value.dayCount - 1) | ||||||
|  |     const endDateObj = fromLocalString(endDateStr, DEFAULT_TZ) | ||||||
|  |     const d = fromLocalString(dateStr, DEFAULT_TZ) | ||||||
|  |     return !isBefore(d, startDateObj) && !isAfter(d, endDateObj) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   function buildMonthLabel({ hasFirst, monthToLabel, labelYear, cur, virtualWeek }) { | ||||||
|  |     if (!hasFirst || monthToLabel === null) return null | ||||||
|  |     if (!labelYear || labelYear > MAX_YEAR) return null | ||||||
|     let weeksSpan = 0 |     let weeksSpan = 0 | ||||||
|     const d = addDays(cur, -1) |     const d = addDays(cur, -1) | ||||||
|     for (let i = 0; i < 6; i++) { |     for (let i = 0; i < 6; i++) { | ||||||
| @@ -175,22 +129,13 @@ export function createVirtualWeekManager({ | |||||||
|     const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1) |     const remainingWeeks = Math.max(1, maxVirtualWeek.value - virtualWeek + 1) | ||||||
|     weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks)) |     weeksSpan = Math.max(1, Math.min(weeksSpan, remainingWeeks)) | ||||||
|     const year = String(labelYear).slice(-2) |     const year = String(labelYear).slice(-2) | ||||||
|         monthLabel = { |     return { | ||||||
|       text: `${getLocalizedMonthName(monthToLabel)} '${year}`, |       text: `${getLocalizedMonthName(monthToLabel)} '${year}`, | ||||||
|       month: monthToLabel, |       month: monthToLabel, | ||||||
|       weeksSpan, |       weeksSpan, | ||||||
|       monthClass: monthAbbr[monthToLabel], |       monthClass: monthAbbr[monthToLabel], | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|     } |  | ||||||
|     return { |  | ||||||
|       virtualWeek, |  | ||||||
|       weekNumber: pad(weekNumber), |  | ||||||
|       days, |  | ||||||
|       monthLabel, |  | ||||||
|       top: (virtualWeek - minVirtualWeek.value) * rowHeight.value, |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   function internalWindowCalc() { |   function internalWindowCalc() { | ||||||
|     const buffer = 6 |     const buffer = 6 | ||||||
| @@ -295,10 +240,6 @@ export function createVirtualWeekManager({ | |||||||
|   // Reflective update of only events inside currently visible weeks (keeps week objects stable) |   // Reflective update of only events inside currently visible weeks (keeps week objects stable) | ||||||
|   function refreshEvents(reason = 'events-refresh') { |   function refreshEvents(reason = 'events-refresh') { | ||||||
|     if (!visibleWeeks.value.length) return |     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 selStart = selection.value.startDate | ||||||
|     const selCount = selection.value.dayCount |     const selCount = selection.value.dayCount | ||||||
|     const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null |     const selEnd = selStart && selCount > 0 ? addDaysStr(selStart, selCount - 1) : null | ||||||
| @@ -306,63 +247,13 @@ export function createVirtualWeekManager({ | |||||||
|       for (const day of week.days) { |       for (const day of week.days) { | ||||||
|         const dateStr = day.date |         const dateStr = day.date | ||||||
|         // Update selection flag |         // Update selection flag | ||||||
|         if (selStart && selEnd) day.isSelected = dateStr >= selStart && dateStr <= selEnd |         if (selStart && selEnd) { | ||||||
|         else day.isSelected = false |           const d = fromLocalString(dateStr, DEFAULT_TZ), | ||||||
|         // Rebuild events list for this day |             s = fromLocalString(selStart, DEFAULT_TZ), | ||||||
|         const storedEvents = [] |             e = fromLocalString(selEnd, DEFAULT_TZ) | ||||||
|         for (const ev of calendarStore.events.values()) { |           day.isSelected = !isBefore(d, s) && !isAfter(d, e) | ||||||
|           if (!ev.recur) { |         } else day.isSelected = false | ||||||
|             const evEnd = toLocalString( |         day.events = buildDayEvents(calendarStore.events.values(), dateStr, DEFAULT_TZ) | ||||||
|               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') { |     if (process.env.NODE_ENV !== 'production') { | ||||||
| @@ -371,6 +262,28 @@ export function createVirtualWeekManager({ | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   // Refresh holiday data for currently visible weeks without rebuilding structure | ||||||
|  |   function refreshHolidays(reason = 'holidays-refresh') { | ||||||
|  |     if (!visibleWeeks.value.length) return | ||||||
|  |     const enabled = calendarStore.config.holidays.enabled | ||||||
|  |     if (enabled) calendarStore._ensureHolidaysInitialized?.() | ||||||
|  |     for (const week of visibleWeeks.value) { | ||||||
|  |       for (const day of week.days) { | ||||||
|  |         if (enabled) { | ||||||
|  |           const holiday = getHolidayForDate(day.date) | ||||||
|  |           ;((day.holiday = holiday), (day.isHoliday = holiday !== null)) | ||||||
|  |         } else { | ||||||
|  |           day.holiday = null | ||||||
|  |           day.isHoliday = false | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (process.env.NODE_ENV !== 'production') { | ||||||
|  |       // eslint-disable-next-line no-console | ||||||
|  |       console.debug('[VirtualWeeks] refreshHolidays', reason, { weeks: visibleWeeks.value.length }) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   function goToToday() { |   function goToToday() { | ||||||
|     const top = addDays(new Date(calendarStore.now), -21) |     const top = addDays(new Date(calendarStore.now), -21) | ||||||
|     const targetWeekIndex = getWeekIndex(top) |     const targetWeekIndex = getWeekIndex(top) | ||||||
| @@ -391,6 +304,7 @@ export function createVirtualWeekManager({ | |||||||
|     resetWeeks, |     resetWeeks, | ||||||
|     updateVisibleWeeks, |     updateVisibleWeeks, | ||||||
|     refreshEvents, |     refreshEvents, | ||||||
|  |     refreshHolidays, | ||||||
|     getWeekIndex, |     getWeekIndex, | ||||||
|     getFirstDayForVirtualWeek, |     getFirstDayForVirtualWeek, | ||||||
|     goToToday, |     goToToday, | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ import { | |||||||
|   fromLocalString, |   fromLocalString, | ||||||
|   getLocaleWeekendDays, |   getLocaleWeekendDays, | ||||||
|   getMondayOfISOWeek, |   getMondayOfISOWeek, | ||||||
|   getOccurrenceDate, |  | ||||||
|   DEFAULT_TZ, |   DEFAULT_TZ, | ||||||
| } from '@/utils/date' | } from '@/utils/date' | ||||||
| import { differenceInCalendarDays, addDays } from 'date-fns' | import { differenceInCalendarDays, addDays } from 'date-fns' | ||||||
| @@ -201,11 +200,6 @@ export const useCalendarStore = defineStore('calendar', { | |||||||
|         this.deleteEvent(baseId) |         this.deleteEvent(baseId) | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
|       const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ) |  | ||||||
|       if (!nextStartStr) { |  | ||||||
|         this.deleteEvent(baseId) |  | ||||||
|         return |  | ||||||
|       } |  | ||||||
|       base.startDate = nextStartStr |       base.startDate = nextStartStr | ||||||
|       // keep same days length |       // keep same days length | ||||||
|       if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1)) |       if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1)) | ||||||
| @@ -228,9 +222,11 @@ export const useCalendarStore = defineStore('calendar', { | |||||||
|       } |       } | ||||||
|       const snapshot = { ...base } |       const snapshot = { ...base } | ||||||
|       snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null |       snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null | ||||||
|  |       if (base.recur.count === occurrenceIndex + 1) { | ||||||
|  |         base.recur.count = occurrenceIndex | ||||||
|  |         return | ||||||
|  |       } | ||||||
|       base.recur.count = occurrenceIndex |       base.recur.count = occurrenceIndex | ||||||
|       const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ) |  | ||||||
|       if (!nextStartStr) return |  | ||||||
|       const originalNumeric = |       const originalNumeric = | ||||||
|         snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10) |         snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10) | ||||||
|       let remainingCount = 'unlimited' |       let remainingCount = 'unlimited' | ||||||
|   | |||||||
| @@ -23,7 +23,7 @@ const monthAbbr = [ | |||||||
|   'nov', |   'nov', | ||||||
|   'dec', |   'dec', | ||||||
| ] | ] | ||||||
| const MIN_YEAR = 100 // less than 100 is interpreted as 19xx | const MIN_YEAR = 1000 | ||||||
| const MAX_YEAR = 9999 | const MAX_YEAR = 9999 | ||||||
|  |  | ||||||
| // Core helpers ------------------------------------------------------------ | // Core helpers ------------------------------------------------------------ | ||||||
| @@ -70,169 +70,7 @@ function getMondayOfISOWeek(date, timeZone = DEFAULT_TZ) { | |||||||
|  |  | ||||||
| const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 | const mondayIndex = (d) => (dateFns.getDay(d) + 6) % 7 | ||||||
|  |  | ||||||
| // Count how many days in [startDate..endDate] match the boolean `pattern` array | // (Recurrence utilities moved to events.js) | ||||||
| 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 --------------------------------------- | // Utility formatting & localization --------------------------------------- | ||||||
| const pad = (n) => String(n).padStart(2, '0') | const pad = (n) => String(n).padStart(2, '0') | ||||||
| @@ -249,11 +87,22 @@ function addDaysStr(str, n, timeZone = DEFAULT_TZ) { | |||||||
|   return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) |   return toLocalString(dateFns.addDays(fromLocalString(str, timeZone), n), timeZone) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // Weekday name helpers now return Sunday-first ordering (index 0 = Sunday ... 6 = Saturday) | ||||||
| function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | function getLocalizedWeekdayNames(timeZone = DEFAULT_TZ) { | ||||||
|   const monday = makeTZDate(2025, 0, 6, timeZone) // a Monday |   const sunday = makeTZDate(2025, 0, 5, timeZone) // a Sunday | ||||||
|   return Array.from({ length: 7 }, (_, i) => |   return Array.from({ length: 7 }, (_, i) => | ||||||
|     new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( |     new Intl.DateTimeFormat(undefined, { weekday: 'short', timeZone }).format( | ||||||
|       dateFns.addDays(monday, i), |       dateFns.addDays(sunday, i), | ||||||
|  |     ), | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Long (wide) localized weekday names, Sunday-first ordering | ||||||
|  | function getLocalizedWeekdayNamesLong(timeZone = DEFAULT_TZ) { | ||||||
|  |   const sunday = makeTZDate(2025, 0, 5, timeZone) | ||||||
|  |   return Array.from({ length: 7 }, (_, i) => | ||||||
|  |     new Intl.DateTimeFormat(undefined, { weekday: 'long', timeZone }).format( | ||||||
|  |       dateFns.addDays(sunday, i), | ||||||
|     ), |     ), | ||||||
|   ) |   ) | ||||||
| } | } | ||||||
| @@ -355,14 +204,12 @@ export { | |||||||
|   // recurrence |   // recurrence | ||||||
|   getMondayOfISOWeek, |   getMondayOfISOWeek, | ||||||
|   mondayIndex, |   mondayIndex, | ||||||
|   getOccurrenceIndex, |  | ||||||
|   getOccurrenceDate, |  | ||||||
|   getVirtualOccurrenceEndDate, |  | ||||||
|   // formatting & localization |   // formatting & localization | ||||||
|   pad, |   pad, | ||||||
|   daysInclusive, |   daysInclusive, | ||||||
|   addDaysStr, |   addDaysStr, | ||||||
|   getLocalizedWeekdayNames, |   getLocalizedWeekdayNames, | ||||||
|  |   getLocalizedWeekdayNamesLong, | ||||||
|   getLocaleFirstDay, |   getLocaleFirstDay, | ||||||
|   getLocaleWeekendDays, |   getLocaleWeekendDays, | ||||||
|   reorderByFirstDay, |   reorderByFirstDay, | ||||||
|   | |||||||
							
								
								
									
										171
									
								
								src/utils/events.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										171
									
								
								src/utils/events.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,171 @@ | |||||||
|  | import * as dateFns from 'date-fns' | ||||||
|  | import { fromLocalString, toLocalString, getMondayOfISOWeek, makeTZDate, DEFAULT_TZ } from './date' | ||||||
|  | import { addDays, isBefore, isAfter, differenceInCalendarDays } from 'date-fns' | ||||||
|  |  | ||||||
|  | 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) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function getNWeekly(event, dateStr, timeZone = DEFAULT_TZ) { | ||||||
|  |   const { recur } = 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 (dateFns.isBefore(target, baseStart)) return null | ||||||
|  |   const dow = dateFns.getDay(target) | ||||||
|  |   if (!pattern[dow]) return null | ||||||
|  |   const interval = recur.interval || 1 | ||||||
|  |   const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone) | ||||||
|  |   const currentBlockStart = getMondayOfISOWeek(target, timeZone) | ||||||
|  |   const weekDiff = dateFns.differenceInCalendarWeeks(currentBlockStart, baseBlockStart) | ||||||
|  |   if (weekDiff < 0 || weekDiff % interval !== 0) return null | ||||||
|  |   const baseDow = dateFns.getDay(baseStart) | ||||||
|  |   const baseCountsAsPattern = !!pattern[baseDow] | ||||||
|  |   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) | ||||||
|  |   const firstWeekCount = countPatternDaysInInterval(baseStart, baseWeekEnd, pattern) | ||||||
|  |   const alignedWeeksBetween = weekDiff / interval - 1 | ||||||
|  |   const fullPatternWeekCount = pattern.filter(Boolean).length | ||||||
|  |   const middleWeeksCount = alignedWeeksBetween > 0 ? alignedWeeksBetween * fullPatternWeekCount : 0 | ||||||
|  |   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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getNMonthly(event, dateStr, timeZone = DEFAULT_TZ) { | ||||||
|  |   const { recur } = 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 getN(event, dateStr, timeZone = DEFAULT_TZ) { | ||||||
|  |   const { recur } = event | ||||||
|  |   if (!recur) return null | ||||||
|  |   const targetDate = fromLocalString(dateStr, timeZone) | ||||||
|  |   const eventStartDate = fromLocalString(event.startDate, timeZone) | ||||||
|  |   if (dateFns.isBefore(targetDate, eventStartDate)) return null | ||||||
|  |   if (recur.freq === 'weeks') return getNWeekly(event, dateStr, timeZone) | ||||||
|  |   if (recur.freq === 'months') return getNMonthly(event, dateStr, timeZone) | ||||||
|  |   return null | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // Reverse lookup: occurrence index -> start date string | ||||||
|  | function getDateWeekly(event, n, timeZone = DEFAULT_TZ) { | ||||||
|  |   const { recur } = event | ||||||
|  |   if (!recur || recur.freq !== 'weeks') return null | ||||||
|  |   if (n < 0 || !Number.isInteger(n)) return null | ||||||
|  |   const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) | ||||||
|  |   if (n >= 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 (n === 0) return toLocalString(baseStart, timeZone) | ||||||
|  |   const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone) | ||||||
|  |   const baseDow = dateFns.getDay(baseStart) | ||||||
|  |   const baseCountsAsPattern = !!pattern[baseDow] | ||||||
|  |   let occ = n | ||||||
|  |   if (!baseCountsAsPattern) occ -= 1 | ||||||
|  |   if (occ < 0) return null | ||||||
|  |   const patternDays = [] | ||||||
|  |   for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d) | ||||||
|  |   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 | ||||||
|  |   const k = Math.floor(remaining / P) + 1 | ||||||
|  |   const indexInWeek = remaining % P | ||||||
|  |   const dow = patternDays[indexInWeek] | ||||||
|  |   const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow) | ||||||
|  |   return toLocalString(occurrenceDate, timeZone) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function getDateMonthly(event, n, timeZone = DEFAULT_TZ) { | ||||||
|  |   const { recur } = event | ||||||
|  |   if (!recur || recur.freq !== 'months') return null | ||||||
|  |   if (n < 0 || !Number.isInteger(n)) return null | ||||||
|  |   const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10) | ||||||
|  |   if (n >= maxCount) return null | ||||||
|  |   const interval = recur.interval || 1 | ||||||
|  |   const baseStart = fromLocalString(event.startDate, timeZone) | ||||||
|  |   const targetMonthOffset = n * interval | ||||||
|  |   const monthDate = dateFns.addMonths(baseStart, targetMonthOffset) | ||||||
|  |   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) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function getDate(event, n, timeZone = DEFAULT_TZ) { | ||||||
|  |   const { recur } = event | ||||||
|  |   if (!recur) return null | ||||||
|  |   if (recur.freq === 'weeks') return getDateWeekly(event, n, timeZone) | ||||||
|  |   if (recur.freq === 'months') return getDateMonthly(event, n, timeZone) | ||||||
|  |   return null | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export function buildDayEvents(events, dateStr, timeZone = DEFAULT_TZ) { | ||||||
|  |   const date = fromLocalString(dateStr, timeZone) | ||||||
|  |   const out = [] | ||||||
|  |   for (const ev of events) { | ||||||
|  |     const spanDays = ev.days || 1 | ||||||
|  |     if (!ev.recur) { | ||||||
|  |       const baseStart = fromLocalString(ev.startDate, timeZone) | ||||||
|  |       const baseEnd = addDays(baseStart, spanDays - 1) | ||||||
|  |       if (!isBefore(date, baseStart) && !isAfter(date, baseEnd)) { | ||||||
|  |         const diffDays = differenceInCalendarDays(date, baseStart) | ||||||
|  |         out.push({ ...ev, n: 0, nDay: diffDays }) | ||||||
|  |       } | ||||||
|  |       continue | ||||||
|  |     } | ||||||
|  |     // Recurring: gather all events whose start for any recurrence lies within spanDays window | ||||||
|  |     const maxBack = Math.min(spanDays - 1, spanScanCap(spanDays)) | ||||||
|  |     for (let back = 0; back <= maxBack; back++) { | ||||||
|  |       const candidateStart = addDays(date, -back) | ||||||
|  |       const candidateStartStr = toLocalString(candidateStart, timeZone) | ||||||
|  |       const n = getN(ev, candidateStartStr, timeZone) | ||||||
|  |       if (n === null) continue | ||||||
|  |       if (back >= spanDays) continue | ||||||
|  |       out.push({ ...ev, n, nDay: back }) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return out | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function spanScanCap(spanDays) { | ||||||
|  |   if (spanDays <= 31) return spanDays - 1 | ||||||
|  |   return Math.min(spanDays - 1, 90) | ||||||
|  | } | ||||||
							
								
								
									
										4
									
								
								src/utils/locale.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/utils/locale.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | export const lang = navigator.language | ||||||
|  | const rtlLangs = new Set(['ar', 'fa', 'he', 'iw', 'ur', 'ps', 'sd', 'ug', 'dv', 'ku', 'yi']) | ||||||
|  | const primary = lang.toLowerCase().split(/[-_]/)[0] | ||||||
|  | export const rtl = rtlLangs.has(primary) | ||||||
		Reference in New Issue
	
	Block a user