// 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
this.eventIdCounter = 1
// Selection state
this.selStart = null
this.selEnd = null
this.isDragging = false
this.dragAnchor = null
// Event drag state
this.dragEventState = null
this.dragPreview = 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 --------
createEvent(eventData) {
const singleDay = eventData.startDate === eventData.endDate
const event = {
id: this.eventIdCounter++,
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
}
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()
}
getEventById(id) {
for (const [, list] of this.events) {
const found = list.find(e => e.id === id)
if (found) return found
}
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
}
}
}
}
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
}
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.eventStartDateInput = this.eventForm.elements['startDate']
this.eventStartTimeInput = this.eventForm.elements['startTime']
this.eventDurationInput = this.eventForm.elements['duration']
this.eventTimeRow = this.eventForm.querySelector('.ec-time-row')
this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]'))
// Duration change toggles time visibility
this.eventDurationInput.addEventListener('change', () => this.updateTimeVisibilityByDuration())
// 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') {
const computed = this.computeDatesFromForm(data)
this.createEvent({
title: data.title.trim(),
startDate: computed.startDate,
endDate: computed.endDate,
colorId: data.colorId,
startTime: data.startTime,
durationMinutes: data.duration
})
this.clearSelection()
} else if (this._dialogMode === 'edit' && this._editingEventId != null) {
const computed = this.computeDatesFromForm(data)
this.applyEventEdit(this._editingEventId, { ...data, ...computed })
}
this.hideEventDialog()
})
this.eventForm.querySelector('[data-action="cancel"]').addEventListener('click', () => {
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.eventStartTimeInput.value = '09:00'
this.eventStartDateInput.value = this.selStart || toLocalString(new Date())
if (this.selStart && this.selEnd) {
const days = daysInclusive(this.selStart, this.selEnd)
this.setDurationValue(days * 1440)
} else {
this.setDurationValue(60)
}
const suggested = this.selectEventColorId(this.selStart, this.selEnd)
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested)
this.updateTimeVisibilityByDuration()
} else if (mode === 'edit') {
const ev = this.getEventById(opts.id)
if (!ev) return
this._editingEventId = ev.id
this.eventTitleInput.value = ev.title || ''
this.eventStartDateInput.value = ev.startDate
if (ev.startDate !== ev.endDate) {
const days = daysInclusive(ev.startDate, ev.endDate)
this.setDurationValue(days * 1440)
} else {
this.setDurationValue(ev.durationMinutes || 60)
}
this.eventStartTimeInput.value = ev.startTime || '09:00'
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0))
this.updateTimeVisibilityByDuration()
}
this.eventModal.hidden = false
setTimeout(() => this.eventTitleInput.focus(), 0)
}
hideEventDialog() {
this.eventModal.hidden = true
}
toggleTimeRow(show) {
if (!this.eventTimeRow) return
this.eventTimeRow.style.display = show ? '' : 'none'
}
updateTimeVisibilityByDuration() {
const minutes = Number(this.eventDurationInput.value || 0)
const isFullDayOrMore = minutes >= 1440
this.toggleTimeRow(!isFullDayOrMore)
}
readEventForm() {
const colorId = Number(this.eventForm.querySelector('input[name="colorId"]:checked')?.value ?? 0)
const timeRowVisible = this.eventTimeRow && this.eventTimeRow.style.display !== 'none'
return {
title: this.eventTitleInput.value,
startDate: this.eventStartDateInput.value,
startTime: timeRowVisible ? (this.eventStartTimeInput.value || '09:00') : null,
duration: timeRowVisible ? Math.max(15, Number(this.eventDurationInput.value) || 60) : null,
colorId
}
}
setDurationValue(minutes) {
const v = String(minutes)
const exists = Array.from(this.eventDurationInput.options).some(o => o.value === v)
if (!exists) {
const opt = document.createElement('option')
opt.value = v
const days = Math.floor(minutes / 1440)
opt.textContent = days >= 1 ? `${days} day${days > 1 ? 's' : ''}` : `${minutes} minutes`
this.eventDurationInput.appendChild(opt)
}
this.eventDurationInput.value = v
}
computeDatesFromForm(data) {
const minutes = Number(this.eventDurationInput.value || 0)
if (minutes >= 1440) {
const days = Math.max(1, Math.floor(minutes / 1440))
return { startDate: data.startDate, endDate: addDaysStr(data.startDate, days - 1) }
}
return { startDate: data.startDate, endDate: data.startDate }
}
// -------- Event Drag & Drop --------
installGlobalEventDragHandlers() {
if (this._installedEventDrag) return
this._installedEventDrag = true
this._onMouseMoveEventDrag = e => this.onEventDragMove(e)
this._onMouseUpEventDrag = e => this.onEventDragEnd(e)
document.addEventListener('mousemove', this._onMouseMoveEventDrag)
document.addEventListener('mouseup', this._onMouseUpEventDrag)
this._onTouchMoveEventDrag = e => this.onEventDragMove(e)
this._onTouchEndEventDrag = e => this.onEventDragEnd(e)
document.addEventListener('touchmove', this._onTouchMoveEventDrag, { passive: false })
document.addEventListener('touchend', this._onTouchEndEventDrag)
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._onWindowMouseUpEventDrag = e => this.onEventDragEnd(e)
this._onWindowBlurEventDrag = () => this.onEventDragEnd()
window.addEventListener('mouseup', this._onWindowMouseUpEventDrag)
window.addEventListener('blur', this._onWindowBlurEventDrag)
}
removeGlobalEventDragHandlers() {
if (!this._installedEventDrag) return
document.removeEventListener('mousemove', this._onMouseMoveEventDrag)
document.removeEventListener('mouseup', this._onMouseUpEventDrag)
document.removeEventListener('touchmove', this._onTouchMoveEventDrag)
document.removeEventListener('touchend', this._onTouchEndEventDrag)
window.removeEventListener('pointermove', this._onPointerMoveEventDrag)
window.removeEventListener('pointerup', this._onPointerUpEventDrag)
window.removeEventListener('pointercancel', this._onPointerCancelEventDrag)
window.removeEventListener('mouseup', this._onWindowMouseUpEventDrag)
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
if (e && e.cancelable) e.preventDefault()
const pt = e.touches ? e.touches[0] : e
const hit = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null
if (hit && hit.date) {
const [s, en] = this.computeTentativeRangeFromPointer(hit.date)
this.dragPreview = { id: this.dragEventState.id, startDate: s, endDate: en }
} else {
this.dragPreview = null
}
this._eventDragMoved = true
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
let startDateStr = this.dragPreview?.startDate
let endDateStr = this.dragPreview?.endDate
if (!startDateStr || !endDateStr) {
const getPoint = ev => (ev && ev.changedTouches && ev.changedTouches[0]) ? ev.changedTouches[0] : (ev && ev.touches ? ev.touches[0] : ev)
const pt = getPoint(e)
const drop = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null
if (drop && drop.date) {
const pair = this.computeTentativeRangeFromPointer(drop.date)
startDateStr = pair[0]
endDateStr = pair[1]
} else {
startDateStr = st.startDate
endDateStr = st.endDate
}
}
const ev = this.getEventById(st.id)
if (ev) {
const updated = { ...ev }
if (st.mode === 'move') {
const spanDays = daysInclusive(ev.startDate, ev.endDate)
updated.startDate = startDateStr
updated.endDate = addDaysStr(startDateStr, spanDays - 1)
} else {
if (startDateStr <= endDateStr) {
updated.startDate = startDateStr
updated.endDate = endDateStr
}
}
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)
}
try {
if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) {
st.element.releasePointerCapture(e.pointerId)
}
} catch {}
this.dragEventState = null
this.justDragged = !!this._eventDragMoved
this._eventDragMoved = false
this.removeGlobalEventDragHandlers()
this.calendar.forceUpdateVisibleWeeks()
setTimeout(() => { this.justDragged = false }, 0)
this.dragPreview = null
}
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]
}
}