// event-manager.js — Event creation, editing, drag/drop, and selection logic import { toLocalString, fromLocalString, daysInclusive, addDaysStr, formatDateRange } from './date-utils.js' export class EventManager { constructor(calendar) { this.calendar = calendar this.events = new Map() // Map of date strings to arrays of events // Selection state this.selStart = null this.selEnd = null this.isDragging = false this.dragAnchor = null // Event drag state this.dragEventState = null this.justDragged = false this._eventDragMoved = false this._installedEventDrag = false this.setupEventDialog() } // -------- Selection Logic -------- clampRange(anchorStr, otherStr) { if (this.calendar.config.select_days <= 1) return [otherStr, otherStr] const limit = this.calendar.config.select_days const forward = fromLocalString(otherStr) >= fromLocalString(anchorStr) const span = daysInclusive(anchorStr, otherStr) if (span <= limit) { const a = [anchorStr, otherStr].sort() return [a[0], a[1]] } if (forward) return [anchorStr, addDaysStr(anchorStr, limit - 1)] return [addDaysStr(anchorStr, -(limit - 1)), anchorStr] } setSelection(aStr, bStr) { const [start, end] = this.clampRange(aStr, bStr) this.selStart = start this.selEnd = end this.applySelectionToVisible() this.calendar.selectedDateInput.value = formatDateRange(fromLocalString(start), fromLocalString(end)) } clearSelection() { this.selStart = null this.selEnd = null for (const [, weekEl] of this.calendar.visibleWeeks) { weekEl.querySelectorAll('.cell.selected').forEach(c => c.classList.remove('selected')) } this.calendar.selectedDateInput.value = '' } applySelectionToVisible() { for (const [, weekEl] of this.calendar.visibleWeeks) { const cells = weekEl.querySelectorAll('.cell[data-date]') for (const cell of cells) { if (!this.selStart || !this.selEnd) { cell.classList.remove('selected') continue } const ds = cell.dataset.date const inRange = ds >= this.selStart && ds <= this.selEnd cell.classList.toggle('selected', inRange) } } } startDrag(dateStr) { if (this.calendar.config.select_days === 0) return this.isDragging = true this.dragAnchor = dateStr this.setSelection(dateStr, dateStr) } updateDrag(dateStr) { if (!this.isDragging) return this.setSelection(this.dragAnchor, dateStr) document.body.style.cursor = 'default' } endDrag(dateStr) { if (!this.isDragging) return this.isDragging = false this.setSelection(this.dragAnchor, dateStr) document.body.style.cursor = 'default' if (this.selStart && this.selEnd) { setTimeout(() => this.showEventDialog('create'), 50) } } // -------- Event Management -------- generateId() { try { if (window.crypto && typeof window.crypto.randomUUID === 'function') { return window.crypto.randomUUID() } } catch {} return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36) } createEvent(eventData) { const singleDay = eventData.startDate === eventData.endDate const event = { id: this.generateId(), title: eventData.title, startDate: eventData.startDate, endDate: eventData.endDate, colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), startTime: singleDay ? (eventData.startTime || '09:00') : null, durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null, // Repeat metadata repeat: eventData.repeat || 'none', repeatCount: eventData.repeatCount || 'unlimited', isRepeating: (eventData.repeat && eventData.repeat !== 'none') } const startDate = new Date(fromLocalString(event.startDate)) const endDate = new Date(fromLocalString(event.endDate)) for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { const dateStr = toLocalString(d) if (!this.events.has(dateStr)) this.events.set(dateStr, []) this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate }) } this.calendar.forceUpdateVisibleWeeks() return event.id } createEventWithRepeat(eventData) { // Just create a single event with repeat metadata return this.createEvent(eventData) } terminateRepeatSeriesAtIndex(baseEventId, terminateAtIndex) { // Find the base event and modify its repeat count to stop before the termination index for (const [, eventList] of this.events) { const baseEvent = eventList.find(e => e.id === baseEventId) if (baseEvent && baseEvent.isRepeating) { // Set the repeat count to stop just before the termination index baseEvent.repeatCount = terminateAtIndex.toString() break } } } moveRepeatSeries(baseEventId, newStartDateStr, newEndDateStr, mode) { // Find the base event and update its dates, which will shift the entire series for (const [, eventList] of this.events) { const baseEvent = eventList.find(e => e.id === baseEventId) if (baseEvent && baseEvent.isRepeating) { const oldStartDate = baseEvent.startDate const oldEndDate = baseEvent.endDate let updatedStartDate, updatedEndDate if (mode === 'move') { const spanDays = daysInclusive(oldStartDate, oldEndDate) updatedStartDate = newStartDateStr updatedEndDate = addDaysStr(newStartDateStr, spanDays - 1) } else { updatedStartDate = newStartDateStr updatedEndDate = newEndDateStr } // Update the base event with the new dates const updated = { ...baseEvent, startDate: updatedStartDate, endDate: updatedEndDate } this.updateEventDatesAndReindex(baseEventId, updated) break } } } generateRepeatOccurrences(baseEvent, targetDateStr) { if (!baseEvent.isRepeating || baseEvent.repeat === 'none') { return [] } const targetDate = new Date(fromLocalString(targetDateStr)) const baseStartDate = new Date(fromLocalString(baseEvent.startDate)) const baseEndDate = new Date(fromLocalString(baseEvent.endDate)) const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000)) const occurrences = [] // Calculate how many intervals have passed since the base event let intervalsPassed = 0 const timeDiff = targetDate - baseStartDate switch (baseEvent.repeat) { case 'daily': intervalsPassed = Math.floor(timeDiff / (24 * 60 * 60 * 1000)) break case 'weekly': intervalsPassed = Math.floor(timeDiff / (7 * 24 * 60 * 60 * 1000)) break case 'biweekly': intervalsPassed = Math.floor(timeDiff / (14 * 24 * 60 * 60 * 1000)) break case 'monthly': intervalsPassed = Math.floor((targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 + (targetDate.getMonth() - baseStartDate.getMonth())) break case 'yearly': intervalsPassed = targetDate.getFullYear() - baseStartDate.getFullYear() break } // Check a few occurrences around the target date for (let i = Math.max(0, intervalsPassed - 2); i <= intervalsPassed + 2; i++) { const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) if (i >= maxOccurrences) break const currentStart = new Date(baseStartDate) switch (baseEvent.repeat) { case 'daily': currentStart.setDate(baseStartDate.getDate() + i) break case 'weekly': currentStart.setDate(baseStartDate.getDate() + i * 7) break case 'biweekly': currentStart.setDate(baseStartDate.getDate() + i * 14) break case 'monthly': currentStart.setMonth(baseStartDate.getMonth() + i) break case 'yearly': currentStart.setFullYear(baseStartDate.getFullYear() + i) break } const currentEnd = new Date(currentStart) currentEnd.setDate(currentStart.getDate() + spanDays) // Check if this occurrence intersects with the target date const currentStartStr = toLocalString(currentStart) const currentEndStr = toLocalString(currentEnd) if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) { occurrences.push({ ...baseEvent, id: `${baseEvent.id}_repeat_${i}`, startDate: currentStartStr, endDate: currentEndStr, isRepeatOccurrence: true, repeatIndex: i }) } } return occurrences } getEventById(id) { // Check for base events first for (const [, list] of this.events) { const found = list.find(e => e.id === id) if (found) return found } // Check if it's a repeat occurrence ID (format: baseId_repeat_index) if (typeof id === 'string' && id.includes('_repeat_')) { const parts = id.split('_repeat_') const baseId = parts[0] // baseId is a string (UUID or similar) const repeatIndex = parseInt(parts[1], 10) if (isNaN(repeatIndex)) return null const baseEvent = this.getEventById(baseId) if (baseEvent && baseEvent.isRepeating) { // Generate the specific occurrence const baseStartDate = new Date(fromLocalString(baseEvent.startDate)) const baseEndDate = new Date(fromLocalString(baseEvent.endDate)) const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000)) const currentStart = new Date(baseStartDate) switch (baseEvent.repeat) { case 'daily': currentStart.setDate(baseStartDate.getDate() + repeatIndex) break case 'weekly': currentStart.setDate(baseStartDate.getDate() + repeatIndex * 7) break case 'biweekly': currentStart.setDate(baseStartDate.getDate() + repeatIndex * 14) break case 'monthly': currentStart.setMonth(baseStartDate.getMonth() + repeatIndex) break case 'yearly': currentStart.setFullYear(baseStartDate.getFullYear() + repeatIndex) break } const currentEnd = new Date(currentStart) currentEnd.setDate(currentStart.getDate() + spanDays) return { ...baseEvent, id: id, startDate: toLocalString(currentStart), endDate: toLocalString(currentEnd), isRepeatOccurrence: true, repeatIndex: repeatIndex, baseEventId: baseId } } } return null } selectEventColorId(startDateStr, endDateStr) { const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] const startDate = new Date(fromLocalString(startDateStr)) const endDate = new Date(fromLocalString(endDateStr)) for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { const dateStr = toLocalString(d) const dayEvents = this.events.get(dateStr) || [] for (const event of dayEvents) { if (event.colorId >= 0 && event.colorId < 8) { colorCounts[event.colorId]++ } } } let minCount = colorCounts[0] let selectedColor = 0 for (let colorId = 1; colorId < 8; colorId++) { if (colorCounts[colorId] < minCount) { minCount = colorCounts[colorId] selectedColor = colorId } } return selectedColor } applyEventEdit(eventId, data) { const current = this.getEventById(eventId) if (!current) return const newStart = data.startDate || current.startDate const newEnd = data.endDate || current.endDate const datesChanged = (newStart !== current.startDate) || (newEnd !== current.endDate) if (datesChanged) { const multi = daysInclusive(newStart, newEnd) > 1 const payload = { ...current, title: data.title.trim(), colorId: data.colorId, startDate: newStart, endDate: newEnd, startTime: multi ? null : (data.startTime ?? current.startTime), durationMinutes: multi ? null : (data.duration ?? current.durationMinutes) } this.updateEventDatesAndReindex(eventId, payload) this.calendar.forceUpdateVisibleWeeks() return } // No date change: update in place across instances for (const [, list] of this.events) { for (let i = 0; i < list.length; i++) { if (list[i].id === eventId) { const isMulti = list[i].startDate !== list[i].endDate list[i] = { ...list[i], title: data.title.trim(), colorId: data.colorId, startTime: isMulti ? null : data.startTime, durationMinutes: isMulti ? null : data.duration, // Update repeat metadata repeat: data.repeat || list[i].repeat || 'none', repeatCount: data.repeatCount || list[i].repeatCount || 'unlimited', isRepeating: (data.repeat && data.repeat !== 'none') || (list[i].repeat && list[i].repeat !== 'none') } } } } this.calendar.forceUpdateVisibleWeeks() } updateEventDatesAndReindex(eventId, updated) { // Remove old instances for (const [date, list] of this.events) { const idx = list.findIndex(e => e.id === eventId) if (idx !== -1) list.splice(idx, 1) if (list.length === 0) this.events.delete(date) } // Re-add across new range const start = new Date(fromLocalString(updated.startDate)) const end = new Date(fromLocalString(updated.endDate)) const base = { id: updated.id, title: updated.title, colorId: updated.colorId, startDate: updated.startDate, endDate: updated.endDate, startTime: updated.startTime, durationMinutes: updated.durationMinutes, // Preserve repeat metadata repeat: updated.repeat || 'none', repeatCount: updated.repeatCount || 'unlimited', isRepeating: updated.isRepeating || false } for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { const ds = toLocalString(d) if (!this.events.has(ds)) this.events.set(ds, []) this.events.get(ds).push({ ...base, isSpanning: start < end }) } } // -------- Event Dialog -------- setupEventDialog() { const tpl = document.createElement('template') tpl.innerHTML = ` ` document.body.appendChild(tpl.content) this.eventModal = document.querySelector('.ec-modal-backdrop') this.eventForm = this.eventModal.querySelector('form.ec-form') this.eventTitleInput = this.eventForm.elements['title'] this.eventRepeatInput = this.eventForm.elements['repeat'] this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]')) // Color selection visual state this.eventColorInputs.forEach(radio => { radio.addEventListener('change', () => { const swatches = this.eventForm.querySelectorAll('.ec-color-swatches .swatch') swatches.forEach(s => s.classList.toggle('selected', s.checked)) }) }) this.eventForm.addEventListener('submit', e => { e.preventDefault() const data = this.readEventForm() if (!data.title.trim()) return if (this._dialogMode === 'create') { this.createEventWithRepeat({ title: data.title.trim(), startDate: this.selStart, endDate: this.selEnd, colorId: data.colorId, repeat: data.repeat, repeatCount: data.repeatCount }) this.clearSelection() } else if (this._dialogMode === 'edit' && this._editingEventId != null) { const editingEvent = this.getEventById(this._editingEventId) if (editingEvent && editingEvent.isRepeatOccurrence && editingEvent.repeatIndex > 0) { // Editing a repeat occurrence that's not the first one // Terminate the original series and create a new event this.terminateRepeatSeriesAtIndex(editingEvent.baseEventId, editingEvent.repeatIndex) this.createEventWithRepeat({ title: data.title.trim(), startDate: editingEvent.startDate, endDate: editingEvent.endDate, colorId: data.colorId, repeat: data.repeat, repeatCount: data.repeatCount }) } else { // Normal event edit this.applyEventEdit(this._editingEventId, { title: data.title.trim(), colorId: data.colorId, repeat: data.repeat, repeatCount: data.repeatCount }) } } this.hideEventDialog() }) this.eventForm.querySelector('[data-action="delete"]').addEventListener('click', () => { if (this._dialogMode === 'edit' && this._editingEventId) { const editingEvent = this.getEventById(this._editingEventId) if (editingEvent && editingEvent.isRepeatOccurrence && editingEvent.repeatIndex > 0) { // Deleting a repeat occurrence that's not the first one // Terminate the original series at this point this.terminateRepeatSeriesAtIndex(editingEvent.baseEventId, editingEvent.repeatIndex) this.calendar.forceUpdateVisibleWeeks() } else { // Normal event deletion - remove from ALL dates it spans across const datesToCleanup = [] for (const [dateStr, eventList] of this.events) { const eventIndex = eventList.findIndex(event => event.id === this._editingEventId) if (eventIndex !== -1) { eventList.splice(eventIndex, 1) // Mark date for cleanup if empty if (eventList.length === 0) { datesToCleanup.push(dateStr) } } } // Clean up empty date entries datesToCleanup.forEach(dateStr => this.events.delete(dateStr)) this.calendar.forceUpdateVisibleWeeks() } } this.hideEventDialog() if (this._dialogMode === 'create') this.clearSelection() }) this.eventModal.addEventListener('click', e => { if (e.target === this.eventModal) this.hideEventDialog() }) document.addEventListener('keydown', e => { if (this.eventModal.hidden) return if (e.key === 'Escape') { this.hideEventDialog() if (this._dialogMode === 'create') this.clearSelection() } }) } showEventDialog(mode, opts = {}) { this._dialogMode = mode this._editingEventId = null if (mode === 'create') { this.eventTitleInput.value = '' this.eventRepeatInput.value = 'none' const suggested = this.selectEventColorId(this.selStart, this.selEnd) this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested) } else if (mode === 'edit') { const ev = this.getEventById(opts.id) if (!ev) return this._editingEventId = ev.id // For repeat occurrences, get the base event's repeat settings let displayEvent = ev if (ev.isRepeatOccurrence && ev.baseEventId) { const baseEvent = this.getEventById(ev.baseEventId) if (baseEvent) { displayEvent = { ...ev, repeat: baseEvent.repeat, repeatCount: baseEvent.repeatCount } } } this.eventTitleInput.value = displayEvent.title || '' this.eventRepeatInput.value = displayEvent.repeat || 'none' this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (displayEvent.colorId ?? 0)) } this.eventModal.hidden = false setTimeout(() => this.eventTitleInput.focus(), 0) } hideEventDialog() { this.eventModal.hidden = true } readEventForm() { const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0) return { title: this.eventTitleInput.value, repeat: this.eventRepeatInput.value, repeatCount: 'unlimited', // Always unlimited colorId } } // -------- Event Drag & Drop -------- installGlobalEventDragHandlers() { if (this._installedEventDrag) return this._installedEventDrag = true this._onPointerMoveEventDrag = e => this.onEventDragMove(e) this._onPointerUpEventDrag = e => this.onEventDragEnd(e) this._onPointerCancelEventDrag = e => this.onEventDragEnd(e) window.addEventListener('pointermove', this._onPointerMoveEventDrag) window.addEventListener('pointerup', this._onPointerUpEventDrag) window.addEventListener('pointercancel', this._onPointerCancelEventDrag) this._onWindowBlurEventDrag = () => this.onEventDragEnd() window.addEventListener('blur', this._onWindowBlurEventDrag) } removeGlobalEventDragHandlers() { if (!this._installedEventDrag) return window.removeEventListener('pointermove', this._onPointerMoveEventDrag) window.removeEventListener('pointerup', this._onPointerUpEventDrag) window.removeEventListener('pointercancel', this._onPointerCancelEventDrag) window.removeEventListener('blur', this._onWindowBlurEventDrag) this._installedEventDrag = false } onEventDragMove(e) { if (!this.dragEventState) return if (this.dragEventState.usingPointer && !(e && e.type && e.type.startsWith('pointer'))) return const pt = e // Check if we've moved far enough to consider this a real drag if (!this._eventDragMoved) { const dx = pt.clientX - this.dragEventState.pointerStartX const dy = pt.clientY - this.dragEventState.pointerStartY const distance = Math.sqrt(dx * dx + dy * dy) const minDragDistance = 5 // pixels if (distance < minDragDistance) return // Only prevent default when we actually start dragging if (e && e.cancelable) e.preventDefault() this._eventDragMoved = true } else { // Already dragging, continue to prevent default if (e && e.cancelable) e.preventDefault() } const hit = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null if (!hit || !hit.date) return const [s, en] = this.computeTentativeRangeFromPointer(hit.date) const ev = this.getEventById(this.dragEventState.id) if (!ev) { // If we already split and created a new base series, keep moving that if (this.dragEventState.splitNewBaseId) { this.moveRepeatSeries(this.dragEventState.splitNewBaseId, s, en, this.dragEventState.mode) this.calendar.forceUpdateVisibleWeeks() } return } // Snapshot origin once if (!this.dragEventState.originSnapshot) { this.dragEventState.originSnapshot = { baseId: ev.isRepeatOccurrence ? ev.baseEventId : ev.id, isRepeat: !!(ev.isRepeatOccurrence || ev.isRepeating), repeatIndex: ev.isRepeatOccurrence ? ev.repeatIndex : 0, startDate: ev.startDate, endDate: ev.endDate } } if (ev.isRepeatOccurrence) { // Live-move: if first occurrence, shift entire series; else split once then move the new future series if (ev.repeatIndex === 0) { this.moveRepeatSeries(ev.baseEventId, s, en, this.dragEventState.mode) } else { // Split only once if (!this.dragEventState.splitNewBaseId) { this.terminateRepeatSeriesAtIndex(ev.baseEventId, ev.repeatIndex) const base = this.getEventById(ev.baseEventId) if (base) { const newId = this.createEventWithRepeat({ title: base.title, startDate: s, endDate: en, colorId: base.colorId, repeat: base.repeat, repeatCount: base.repeatCount }) this.dragEventState.splitNewBaseId = newId } } else { this.moveRepeatSeries(this.dragEventState.splitNewBaseId, s, en, this.dragEventState.mode) } } } else { // Non-repeating: mutate directly and repaint const updated = { ...ev } if (this.dragEventState.mode === 'move') { const spanDays = daysInclusive(ev.startDate, ev.endDate) updated.startDate = s updated.endDate = addDaysStr(s, spanDays - 1) } else { if (s <= en) { updated.startDate = s updated.endDate = en } } let [ns, ne] = this.normalizeDateOrder(updated.startDate, updated.endDate) updated.startDate = ns updated.endDate = ne const multi = daysInclusive(updated.startDate, updated.endDate) > 1 if (multi) { updated.startTime = null updated.durationMinutes = null } else { if (!updated.startTime) updated.startTime = '09:00' if (!updated.durationMinutes) updated.durationMinutes = 60 } this.updateEventDatesAndReindex(ev.id, updated) } this.calendar.forceUpdateVisibleWeeks() } onEventDragEnd(e) { if (!this.dragEventState) return if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) { return } const st = this.dragEventState // If no actual drag movement occurred, do nothing (treat as click) if (!this._eventDragMoved) { // clean up only try { if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) { st.element.releasePointerCapture(e.pointerId) } } catch {} this.dragEventState = null this.justDragged = false this._eventDragMoved = false this.removeGlobalEventDragHandlers() return } try { if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) { st.element.releasePointerCapture(e.pointerId) } } catch {} this.dragEventState = null // Only set justDragged if we actually moved and dragged this.justDragged = !!this._eventDragMoved this._eventDragMoved = false this.removeGlobalEventDragHandlers() // We already applied live updates during drag; ensure final repaint if (this.justDragged) this.calendar.forceUpdateVisibleWeeks() // Clear justDragged flag after a short delay to allow click events to process if (this.justDragged) { setTimeout(() => { this.justDragged = false }, 100) } // no preview state to clear } computeTentativeRangeFromPointer(dropDateStr) { const st = this.dragEventState if (!st) return [null, null] const anchorOffset = st.anchorOffset || 0 const spanDays = st.originSpanDays || daysInclusive(st.startDate, st.endDate) let startStr = st.startDate let endStr = st.endDate if (st.mode === 'move') { startStr = addDaysStr(dropDateStr, -anchorOffset) endStr = addDaysStr(startStr, spanDays - 1) } else if (st.mode === 'resize-left') { startStr = dropDateStr endStr = st.originalEndDate || st.endDate } else if (st.mode === 'resize-right') { startStr = st.originalStartDate || st.startDate endStr = dropDateStr } const [ns, ne] = this.normalizeDateOrder(startStr, endStr) return [ns, ne] } normalizeDateOrder(aStr, bStr) { if (!aStr) return [bStr, bStr] if (!bStr) return [aStr, aStr] return aStr <= bStr ? [aStr, bStr] : [bStr, aStr] } addEventsToWeek(weekEl) { const daysGrid = weekEl._daysGrid || weekEl.querySelector('.days-grid') const overlay = weekEl._overlay || weekEl.querySelector('.week-overlay') if (!daysGrid || !overlay) return const cells = Array.from(daysGrid.querySelectorAll('.cell[data-date]')) while (overlay.firstChild) overlay.removeChild(overlay.firstChild) const weekEvents = new Map() // Collect all repeating events from the entire events map const allRepeatingEvents = [] for (const [, eventList] of this.events) { for (const event of eventList) { if (event.isRepeating && !allRepeatingEvents.some(e => e.id === event.id)) { allRepeatingEvents.push(event) } } } for (const cell of cells) { const dateStr = cell.dataset.date const events = this.events.get(dateStr) || [] // Add regular events for (const ev of events) { if (!weekEvents.has(ev.id)) { weekEvents.set(ev.id, { ...ev, startDateInWeek: dateStr, endDateInWeek: dateStr, startIdx: cells.indexOf(cell), endIdx: cells.indexOf(cell) }) } else { const w = weekEvents.get(ev.id) w.endDateInWeek = dateStr w.endIdx = cells.indexOf(cell) } } // Generate repeat occurrences for this date for (const baseEvent of allRepeatingEvents) { const repeatOccurrences = this.generateRepeatOccurrences(baseEvent, dateStr) for (const repeatEvent of repeatOccurrences) { // Skip if this is the original occurrence (already added above) if (repeatEvent.startDate === baseEvent.startDate) continue if (!weekEvents.has(repeatEvent.id)) { weekEvents.set(repeatEvent.id, { ...repeatEvent, startDateInWeek: dateStr, endDateInWeek: dateStr, startIdx: cells.indexOf(cell), endIdx: cells.indexOf(cell) }) } else { const w = weekEvents.get(repeatEvent.id) w.endDateInWeek = dateStr w.endIdx = cells.indexOf(cell) } } } } // No special preview overlay logic: we mutate events live during drag const timeToMin = t => { if (typeof t !== 'string') return 1e9 const m = t.match(/^(\d{2}):(\d{2})/) if (!m) return 1e9 return Number(m[1]) * 60 + Number(m[2]) } const spans = Array.from(weekEvents.values()).sort((a, b) => { if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx // Prefer longer spans to be placed first for packing const aLen = a.endIdx - a.startIdx const bLen = b.endIdx - b.startIdx if (aLen !== bLen) return bLen - aLen // Within the same day and same span length, order by start time const at = timeToMin(a.startTime) const bt = timeToMin(b.startTime) if (at !== bt) return at - bt // Stable fallback by id return String(a.id).localeCompare(String(b.id)) }) const rowsLastEnd = [] for (const w of spans) { let placedRow = 0 while (placedRow < rowsLastEnd.length && !(w.startIdx > rowsLastEnd[placedRow])) placedRow++ if (placedRow === rowsLastEnd.length) rowsLastEnd.push(-1) rowsLastEnd[placedRow] = w.endIdx w._row = placedRow + 1 } const numRows = Math.max(1, rowsLastEnd.length) // Decide between "comfortable" layout (with gaps, not stretched) // and "compressed" layout (fractional rows, no gaps) based on fit. const cs = getComputedStyle(overlay) const overlayHeight = overlay.getBoundingClientRect().height const marginTopPx = parseFloat(cs.marginTop) || 0 const available = Math.max(0, overlayHeight - marginTopPx) const baseEm = parseFloat(cs.fontSize) || 16 const rowPx = 1.2 * baseEm // preferred row height ~ 1.2em const gapPx = 0.2 * baseEm // preferred gap ~ .2em const needed = numRows * rowPx + (numRows - 1) * gapPx if (needed <= available) { // Comfortable: keep gaps and do not stretch rows to fill overlay.style.gridTemplateRows = `repeat(${numRows}, ${rowPx}px)` overlay.style.rowGap = `${gapPx}px` } else { // Compressed: use fractional rows so everything fits; remove gaps overlay.style.gridTemplateRows = `repeat(${numRows}, 1fr)` overlay.style.rowGap = '0' } // Create the spans for (const w of spans) this.createOverlaySpan(overlay, w, weekEl) } createOverlaySpan(overlay, w, weekEl) { const span = document.createElement('div') span.className = `event-span event-color-${w.colorId}` span.style.gridColumn = `${w.startIdx + 1} / ${w.endIdx + 2}` span.style.gridRow = `${w._row}` span.textContent = w.title span.title = `${w.title} (${w.startDate === w.endDate ? w.startDate : w.startDate + ' - ' + w.endDate})` span.dataset.eventId = String(w.id) if (this.dragEventState && this.dragEventState.id === w.id) span.classList.add('dragging') // Click opens edit if not dragging span.addEventListener('click', e => { e.stopPropagation() // Only block if we actually dragged (moved the mouse) if (this.justDragged) return this.showEventDialog('edit', { id: w.id }) }) // Add resize handles const left = document.createElement('div') left.className = 'resize-handle left' const right = document.createElement('div') right.className = 'resize-handle right' span.appendChild(left) span.appendChild(right) // Pointer down handlers const onPointerDown = (mode, ev) => { // Prevent duplicate handling if we already have a drag state if (this.dragEventState) return // Don't prevent default immediately - let click events through ev.stopPropagation() const point = ev const hitAtStart = this.calendar.getDateUnderPointer(point.clientX, point.clientY) this.dragEventState = { mode, id: w.id, originWeek: weekEl, originStartIdx: w.startIdx, originEndIdx: w.endIdx, pointerStartX: point.clientX, pointerStartY: point.clientY, startDate: w.startDate, endDate: w.endDate, usingPointer: ev.type && ev.type.startsWith('pointer') } // compute anchor offset within the event based on where the pointer is const spanDays = daysInclusive(w.startDate, w.endDate) let anchorOffset = 0 if (hitAtStart && hitAtStart.date) { const anchorDate = hitAtStart.date // clamp anchorDate to within event span if (anchorDate < w.startDate) anchorOffset = 0 else if (anchorDate > w.endDate) anchorOffset = spanDays - 1 else anchorOffset = daysInclusive(w.startDate, anchorDate) - 1 } this.dragEventState.anchorOffset = anchorOffset this.dragEventState.originSpanDays = spanDays this.dragEventState.originalStartDate = w.startDate this.dragEventState.originalEndDate = w.endDate // capture pointer to ensure we receive the up even if cursor leaves element if (this.dragEventState.usingPointer && span.setPointerCapture && ev.pointerId != null) { try { span.setPointerCapture(ev.pointerId) } catch {} } this.dragEventState.element = span this.dragEventState.currentOverlay = overlay this._eventDragMoved = false span.classList.add('dragging') this.installGlobalEventDragHandlers() } // Use pointer events (supported by all modern browsers) left.addEventListener('pointerdown', e => onPointerDown('resize-left', e)) right.addEventListener('pointerdown', e => onPointerDown('resize-right', e)) span.addEventListener('pointerdown', e => { if ((e.target).classList && (e.target).classList.contains('resize-handle')) return onPointerDown('move', e) }) // Pointer events cover mouse and touch overlay.appendChild(span) } }