calendar/event-manager.js
2025-08-21 06:44:21 -06:00

1031 lines
36 KiB
JavaScript

// 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 = `
<div class="ec-modal-backdrop" part="backdrop" hidden>
<div class="ec-modal" role="dialog" aria-modal="true" aria-labelledby="ec-modal-title">
<form class="ec-form" novalidate>
<header class="ec-header">
<h2 id="ec-modal-title">Event</h2>
</header>
<div class="ec-body">
<label class="ec-field">
<span>Title</span>
<input type="text" name="title" autocomplete="off" required />
</label>
<label class="ec-field">
<span>Repeat</span>
<select name="repeat">
<option value="none">No repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Every 2 weeks</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</label>
<div class="ec-color-swatches">
${Array.from({ length: 8 }, (_, i) => `
<input class="swatch event-color-${i}" type="radio" name="colorId" value="${i}">
`).join('')}
</div>
</div>
<footer class="ec-footer">
<button type="button" class="ec-btn" data-action="delete">Delete</button>
<button type="submit" class="ec-btn primary">Save</button>
</footer>
</form>
</div>
</div>`
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)
}
}