Repeated events support

This commit is contained in:
Leo Vasanko 2025-08-21 06:44:21 -06:00
parent 00936b33ea
commit d3a9c323c3

View File

@ -11,7 +11,6 @@ export class EventManager {
constructor(calendar) { constructor(calendar) {
this.calendar = calendar this.calendar = calendar
this.events = new Map() // Map of date strings to arrays of events this.events = new Map() // Map of date strings to arrays of events
this.eventIdCounter = 1
// Selection state // Selection state
this.selStart = null this.selStart = null
@ -20,8 +19,7 @@ export class EventManager {
this.dragAnchor = null this.dragAnchor = null
// Event drag state // Event drag state
this.dragEventState = null this.dragEventState = null
this.dragPreview = null
this.justDragged = false this.justDragged = false
this._eventDragMoved = false this._eventDragMoved = false
this._installedEventDrag = false this._installedEventDrag = false
@ -101,16 +99,29 @@ export class EventManager {
// -------- Event Management -------- // -------- 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) { createEvent(eventData) {
const singleDay = eventData.startDate === eventData.endDate const singleDay = eventData.startDate === eventData.endDate
const event = { const event = {
id: this.eventIdCounter++, id: this.generateId(),
title: eventData.title, title: eventData.title,
startDate: eventData.startDate, startDate: eventData.startDate,
endDate: eventData.endDate, endDate: eventData.endDate,
colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
startTime: singleDay ? (eventData.startTime || '09:00') : null, startTime: singleDay ? (eventData.startTime || '09:00') : null,
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null,
// Repeat metadata
repeat: eventData.repeat || 'none',
repeatCount: eventData.repeatCount || 'unlimited',
isRepeating: (eventData.repeat && eventData.repeat !== 'none')
} }
const startDate = new Date(fromLocalString(event.startDate)) const startDate = new Date(fromLocalString(event.startDate))
@ -122,71 +133,196 @@ export class EventManager {
this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate }) this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
} }
this.calendar.forceUpdateVisibleWeeks() this.calendar.forceUpdateVisibleWeeks()
return event.id
} }
createEventWithRepeat(eventData) { createEventWithRepeat(eventData) {
const { repeat, repeatCount, ...baseEventData } = eventData // Just create a single event with repeat metadata
return this.createEvent(eventData)
if (repeat === 'none') { }
// Single event
this.createEvent(baseEventData) terminateRepeatSeriesAtIndex(baseEventId, terminateAtIndex) {
return // 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 []
} }
// Calculate dates for repeating events const targetDate = new Date(fromLocalString(targetDateStr))
const startDate = new Date(fromLocalString(baseEventData.startDate)) const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
const endDate = new Date(fromLocalString(baseEventData.endDate)) const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
const spanDays = Math.floor((endDate - startDate) / (24 * 60 * 60 * 1000)) const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
const maxOccurrences = repeatCount === 'unlimited' ? 104 : parseInt(repeatCount, 10) // Cap unlimited at ~2 years const occurrences = []
const dates = []
for (let i = 0; i < maxOccurrences; i++) { // Calculate how many intervals have passed since the base event
const currentStart = new Date(startDate) 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
switch (repeat) { const currentStart = new Date(baseStartDate)
switch (baseEvent.repeat) {
case 'daily': case 'daily':
currentStart.setDate(startDate.getDate() + i) currentStart.setDate(baseStartDate.getDate() + i)
break break
case 'weekly': case 'weekly':
currentStart.setDate(startDate.getDate() + i * 7) currentStart.setDate(baseStartDate.getDate() + i * 7)
break break
case 'biweekly': case 'biweekly':
currentStart.setDate(startDate.getDate() + i * 14) currentStart.setDate(baseStartDate.getDate() + i * 14)
break break
case 'monthly': case 'monthly':
currentStart.setMonth(startDate.getMonth() + i) currentStart.setMonth(baseStartDate.getMonth() + i)
break break
case 'yearly': case 'yearly':
currentStart.setFullYear(startDate.getFullYear() + i) currentStart.setFullYear(baseStartDate.getFullYear() + i)
break break
} }
const currentEnd = new Date(currentStart) const currentEnd = new Date(currentStart)
currentEnd.setDate(currentStart.getDate() + spanDays) currentEnd.setDate(currentStart.getDate() + spanDays)
dates.push({ // Check if this occurrence intersects with the target date
startDate: toLocalString(currentStart), const currentStartStr = toLocalString(currentStart)
endDate: toLocalString(currentEnd) 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
})
}
} }
// Create events for all dates return occurrences
dates.forEach(({ startDate, endDate }) => {
this.createEvent({
...baseEventData,
startDate,
endDate
})
})
} }
getEventById(id) { getEventById(id) {
// Check for base events first
for (const [, list] of this.events) { for (const [, list] of this.events) {
const found = list.find(e => e.id === id) const found = list.find(e => e.id === id)
if (found) return found 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 return null
} }
@ -249,7 +385,11 @@ export class EventManager {
title: data.title.trim(), title: data.title.trim(),
colorId: data.colorId, colorId: data.colorId,
startTime: isMulti ? null : data.startTime, startTime: isMulti ? null : data.startTime,
durationMinutes: isMulti ? null : data.duration 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')
} }
} }
} }
@ -274,7 +414,11 @@ export class EventManager {
startDate: updated.startDate, startDate: updated.startDate,
endDate: updated.endDate, endDate: updated.endDate,
startTime: updated.startTime, startTime: updated.startTime,
durationMinutes: updated.durationMinutes 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)) { for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const ds = toLocalString(d) const ds = toLocalString(d)
@ -310,20 +454,6 @@ export class EventManager {
<option value="yearly">Yearly</option> <option value="yearly">Yearly</option>
</select> </select>
</label> </label>
<div class="ec-repeat-count-row" style="display: none;">
<label class="ec-field">
<span>Number of occurrences</span>
<select name="repeatCount">
<option value="2">2 times</option>
<option value="3">3 times</option>
<option value="4">4 times</option>
<option value="5">5 times</option>
<option value="10">10 times</option>
<option value="52">52 times (1 year)</option>
<option value="unlimited">Unlimited</option>
</select>
</label>
</div>
<div class="ec-color-swatches"> <div class="ec-color-swatches">
${Array.from({ length: 8 }, (_, i) => ` ${Array.from({ length: 8 }, (_, i) => `
<input class="swatch event-color-${i}" type="radio" name="colorId" value="${i}"> <input class="swatch event-color-${i}" type="radio" name="colorId" value="${i}">
@ -343,16 +473,8 @@ export class EventManager {
this.eventForm = this.eventModal.querySelector('form.ec-form') this.eventForm = this.eventModal.querySelector('form.ec-form')
this.eventTitleInput = this.eventForm.elements['title'] this.eventTitleInput = this.eventForm.elements['title']
this.eventRepeatInput = this.eventForm.elements['repeat'] this.eventRepeatInput = this.eventForm.elements['repeat']
this.eventRepeatCountInput = this.eventForm.elements['repeatCount']
this.eventRepeatCountRow = this.eventForm.querySelector('.ec-repeat-count-row')
this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]')) this.eventColorInputs = Array.from(this.eventForm.querySelectorAll('input[name="colorId"]'))
// Repeat change toggles repeat count visibility
this.eventRepeatInput.addEventListener('change', () => {
const showRepeatCount = this.eventRepeatInput.value !== 'none'
this.eventRepeatCountRow.style.display = showRepeatCount ? 'block' : 'none'
})
// Color selection visual state // Color selection visual state
this.eventColorInputs.forEach(radio => { this.eventColorInputs.forEach(radio => {
radio.addEventListener('change', () => { radio.addEventListener('change', () => {
@ -377,33 +499,59 @@ export class EventManager {
}) })
this.clearSelection() this.clearSelection()
} else if (this._dialogMode === 'edit' && this._editingEventId != null) { } else if (this._dialogMode === 'edit' && this._editingEventId != null) {
this.applyEventEdit(this._editingEventId, { const editingEvent = this.getEventById(this._editingEventId)
title: data.title.trim(),
colorId: data.colorId, if (editingEvent && editingEvent.isRepeatOccurrence && editingEvent.repeatIndex > 0) {
repeat: data.repeat, // Editing a repeat occurrence that's not the first one
repeatCount: data.repeatCount // 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.hideEventDialog()
}) })
this.eventForm.querySelector('[data-action="delete"]').addEventListener('click', () => { this.eventForm.querySelector('[data-action="delete"]').addEventListener('click', () => {
if (this._dialogMode === 'edit' && this._editingEventId) { if (this._dialogMode === 'edit' && this._editingEventId) {
// Find and remove the event from ALL dates it spans across const editingEvent = this.getEventById(this._editingEventId)
const datesToCleanup = []
for (const [dateStr, eventList] of this.events) { if (editingEvent && editingEvent.isRepeatOccurrence && editingEvent.repeatIndex > 0) {
const eventIndex = eventList.findIndex(event => event.id === this._editingEventId) // Deleting a repeat occurrence that's not the first one
if (eventIndex !== -1) { // Terminate the original series at this point
eventList.splice(eventIndex, 1) this.terminateRepeatSeriesAtIndex(editingEvent.baseEventId, editingEvent.repeatIndex)
// Mark date for cleanup if empty this.calendar.forceUpdateVisibleWeeks()
if (eventList.length === 0) { } else {
datesToCleanup.push(dateStr) // 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()
} }
// Clean up empty date entries
datesToCleanup.forEach(dateStr => this.events.delete(dateStr))
this.calendar.forceUpdateVisibleWeeks()
} }
this.hideEventDialog() this.hideEventDialog()
if (this._dialogMode === 'create') this.clearSelection() if (this._dialogMode === 'create') this.clearSelection()
@ -429,19 +577,25 @@ export class EventManager {
if (mode === 'create') { if (mode === 'create') {
this.eventTitleInput.value = '' this.eventTitleInput.value = ''
this.eventRepeatInput.value = 'none' this.eventRepeatInput.value = 'none'
this.eventRepeatCountInput.value = '5'
this.eventRepeatCountRow.style.display = 'none'
const suggested = this.selectEventColorId(this.selStart, this.selEnd) const suggested = this.selectEventColorId(this.selStart, this.selEnd)
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested) this.eventColorInputs.forEach(r => r.checked = Number(r.value) === suggested)
} else if (mode === 'edit') { } else if (mode === 'edit') {
const ev = this.getEventById(opts.id) const ev = this.getEventById(opts.id)
if (!ev) return if (!ev) return
this._editingEventId = ev.id this._editingEventId = ev.id
this.eventTitleInput.value = ev.title || ''
this.eventRepeatInput.value = ev.repeat || 'none' // For repeat occurrences, get the base event's repeat settings
this.eventRepeatCountInput.value = ev.repeatCount || '5' let displayEvent = ev
this.eventRepeatCountRow.style.display = (ev.repeat && ev.repeat !== 'none') ? 'block' : 'none' if (ev.isRepeatOccurrence && ev.baseEventId) {
this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0)) 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 this.eventModal.hidden = false
setTimeout(() => this.eventTitleInput.focus(), 0) setTimeout(() => this.eventTitleInput.focus(), 0)
@ -456,7 +610,7 @@ export class EventManager {
return { return {
title: this.eventTitleInput.value, title: this.eventTitleInput.value,
repeat: this.eventRepeatInput.value, repeat: this.eventRepeatInput.value,
repeatCount: this.eventRepeatCountInput.value, repeatCount: 'unlimited', // Always unlimited
colorId colorId
} }
} }
@ -466,48 +620,29 @@ export class EventManager {
installGlobalEventDragHandlers() { installGlobalEventDragHandlers() {
if (this._installedEventDrag) return if (this._installedEventDrag) return
this._installedEventDrag = true 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._onPointerMoveEventDrag = e => this.onEventDragMove(e)
this._onPointerUpEventDrag = e => this.onEventDragEnd(e) this._onPointerUpEventDrag = e => this.onEventDragEnd(e)
this._onPointerCancelEventDrag = e => this.onEventDragEnd(e) this._onPointerCancelEventDrag = e => this.onEventDragEnd(e)
window.addEventListener('pointermove', this._onPointerMoveEventDrag) window.addEventListener('pointermove', this._onPointerMoveEventDrag)
window.addEventListener('pointerup', this._onPointerUpEventDrag) window.addEventListener('pointerup', this._onPointerUpEventDrag)
window.addEventListener('pointercancel', this._onPointerCancelEventDrag) window.addEventListener('pointercancel', this._onPointerCancelEventDrag)
this._onWindowMouseUpEventDrag = e => this.onEventDragEnd(e)
this._onWindowBlurEventDrag = () => this.onEventDragEnd() this._onWindowBlurEventDrag = () => this.onEventDragEnd()
window.addEventListener('mouseup', this._onWindowMouseUpEventDrag)
window.addEventListener('blur', this._onWindowBlurEventDrag) window.addEventListener('blur', this._onWindowBlurEventDrag)
} }
removeGlobalEventDragHandlers() { removeGlobalEventDragHandlers() {
if (!this._installedEventDrag) return 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('pointermove', this._onPointerMoveEventDrag)
window.removeEventListener('pointerup', this._onPointerUpEventDrag) window.removeEventListener('pointerup', this._onPointerUpEventDrag)
window.removeEventListener('pointercancel', this._onPointerCancelEventDrag) window.removeEventListener('pointercancel', this._onPointerCancelEventDrag)
window.removeEventListener('mouseup', this._onWindowMouseUpEventDrag)
window.removeEventListener('blur', this._onWindowBlurEventDrag) window.removeEventListener('blur', this._onWindowBlurEventDrag)
this._installedEventDrag = false this._installedEventDrag = false
} }
onEventDragMove(e) { onEventDragMove(e) {
if (!this.dragEventState) return if (!this.dragEventState) return
if (this.dragEventState.usingPointer && !(e && e.type && e.type.startsWith('pointer'))) return if (this.dragEventState.usingPointer && !(e && e.type && e.type.startsWith('pointer'))) return
const pt = e
const pt = e.touches ? e.touches[0] : e
// Check if we've moved far enough to consider this a real drag // Check if we've moved far enough to consider this a real drag
if (!this._eventDragMoved) { if (!this._eventDragMoved) {
@ -516,9 +651,7 @@ export class EventManager {
const distance = Math.sqrt(dx * dx + dy * dy) const distance = Math.sqrt(dx * dx + dy * dy)
const minDragDistance = 5 // pixels const minDragDistance = 5 // pixels
if (distance < minDragDistance) { if (distance < minDragDistance) return
return // Don't start dragging yet
}
// Only prevent default when we actually start dragging // Only prevent default when we actually start dragging
if (e && e.cancelable) e.preventDefault() if (e && e.cancelable) e.preventDefault()
this._eventDragMoved = true this._eventDragMoved = true
@ -527,52 +660,66 @@ export class EventManager {
if (e && e.cancelable) e.preventDefault() if (e && e.cancelable) e.preventDefault()
} }
const hit = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null const hit = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null
if (hit && hit.date) { if (!hit || !hit.date) return
const [s, en] = this.computeTentativeRangeFromPointer(hit.date) const [s, en] = this.computeTentativeRangeFromPointer(hit.date)
this.dragPreview = { id: this.dragEventState.id, startDate: s, endDate: en }
} else {
this.dragPreview = null
}
this.calendar.forceUpdateVisibleWeeks()
}
onEventDragEnd(e) { const ev = this.getEventById(this.dragEventState.id)
if (!this.dragEventState) return if (!ev) {
if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) { // 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 return
} }
const st = this.dragEventState // Snapshot origin once
if (!this.dragEventState.originSnapshot) {
let startDateStr = this.dragPreview?.startDate this.dragEventState.originSnapshot = {
let endDateStr = this.dragPreview?.endDate baseId: ev.isRepeatOccurrence ? ev.baseEventId : ev.id,
isRepeat: !!(ev.isRepeatOccurrence || ev.isRepeating),
if (!startDateStr || !endDateStr) { repeatIndex: ev.isRepeatOccurrence ? ev.repeatIndex : 0,
const getPoint = ev => (ev && ev.changedTouches && ev.changedTouches[0]) ? ev.changedTouches[0] : (ev && ev.touches ? ev.touches[0] : ev) startDate: ev.startDate,
const pt = getPoint(e) endDate: ev.endDate
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.isRepeatOccurrence) {
if (ev) { // Live-move: if first occurrence, shift entire series; else split once then move the new future series
const updated = { ...ev } if (ev.repeatIndex === 0) {
if (st.mode === 'move') { this.moveRepeatSeries(ev.baseEventId, s, en, this.dragEventState.mode)
const spanDays = daysInclusive(ev.startDate, ev.endDate)
updated.startDate = startDateStr
updated.endDate = addDaysStr(startDateStr, spanDays - 1)
} else { } else {
if (startDateStr <= endDateStr) { // Split only once
updated.startDate = startDateStr if (!this.dragEventState.splitNewBaseId) {
updated.endDate = endDateStr 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
} }
} }
@ -587,10 +734,35 @@ export class EventManager {
if (!updated.startTime) updated.startTime = '09:00' if (!updated.startTime) updated.startTime = '09:00'
if (!updated.durationMinutes) updated.durationMinutes = 60 if (!updated.durationMinutes) updated.durationMinutes = 60
} }
this.updateEventDatesAndReindex(ev.id, updated) 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 { try {
if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) { if (st.usingPointer && st.element && st.element.releasePointerCapture && e && e.pointerId != null) {
st.element.releasePointerCapture(e.pointerId) st.element.releasePointerCapture(e.pointerId)
@ -605,10 +777,8 @@ export class EventManager {
this._eventDragMoved = false this._eventDragMoved = false
this.removeGlobalEventDragHandlers() this.removeGlobalEventDragHandlers()
// Only update visible weeks if we actually dragged // We already applied live updates during drag; ensure final repaint
if (this.justDragged) { if (this.justDragged) this.calendar.forceUpdateVisibleWeeks()
this.calendar.forceUpdateVisibleWeeks()
}
// Clear justDragged flag after a short delay to allow click events to process // Clear justDragged flag after a short delay to allow click events to process
if (this.justDragged) { if (this.justDragged) {
@ -616,7 +786,7 @@ export class EventManager {
this.justDragged = false this.justDragged = false
}, 100) }, 100)
} }
this.dragPreview = null // no preview state to clear
} }
computeTentativeRangeFromPointer(dropDateStr) { computeTentativeRangeFromPointer(dropDateStr) {
@ -656,9 +826,22 @@ export class EventManager {
while (overlay.firstChild) overlay.removeChild(overlay.firstChild) while (overlay.firstChild) overlay.removeChild(overlay.firstChild)
const weekEvents = new Map() 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) { for (const cell of cells) {
const dateStr = cell.dataset.date const dateStr = cell.dataset.date
const events = this.events.get(dateStr) || [] const events = this.events.get(dateStr) || []
// Add regular events
for (const ev of events) { for (const ev of events) {
if (!weekEvents.has(ev.id)) { if (!weekEvents.has(ev.id)) {
weekEvents.set(ev.id, { weekEvents.set(ev.id, {
@ -674,49 +857,33 @@ export class EventManager {
w.endIdx = cells.indexOf(cell) w.endIdx = cells.indexOf(cell)
} }
} }
}
// Generate repeat occurrences for this date
// If dragging, hide the original of the dragged event and inject preview if it intersects this week for (const baseEvent of allRepeatingEvents) {
if (this.dragPreview && this.dragPreview.id != null) { const repeatOccurrences = this.generateRepeatOccurrences(baseEvent, dateStr)
const pv = this.dragPreview for (const repeatEvent of repeatOccurrences) {
// Remove original entries of the dragged event for this week to prevent ghosts // Skip if this is the original occurrence (already added above)
if (weekEvents.has(pv.id)) weekEvents.delete(pv.id) if (repeatEvent.startDate === baseEvent.startDate) continue
// Determine week range
const weekStart = cells[0]?.dataset?.date if (!weekEvents.has(repeatEvent.id)) {
const weekEnd = cells[cells.length - 1]?.dataset?.date weekEvents.set(repeatEvent.id, {
if (weekStart && weekEnd) { ...repeatEvent,
const s = pv.startDate startDateInWeek: dateStr,
const e = pv.endDate endDateInWeek: dateStr,
// Intersect preview with this week startIdx: cells.indexOf(cell),
const startInWeek = s <= weekEnd && e >= weekStart ? (s < weekStart ? weekStart : s) : null endIdx: cells.indexOf(cell)
const endInWeek = s <= weekEnd && e >= weekStart ? (e > weekEnd ? weekEnd : e) : null })
if (startInWeek && endInWeek) { } else {
// Compute indices const w = weekEvents.get(repeatEvent.id)
let sIdx = cells.findIndex(c => c.dataset.date === startInWeek) w.endDateInWeek = dateStr
if (sIdx === -1) sIdx = cells.findIndex(c => c.dataset.date > startInWeek) w.endIdx = cells.indexOf(cell)
if (sIdx === -1) sIdx = 0
let eIdx = -1
for (let i = 0; i < cells.length; i++) {
if (cells[i].dataset.date <= endInWeek) eIdx = i
}
if (eIdx === -1) eIdx = cells.length - 1
// Build/override entry
const baseEv = this.getEventById(pv.id)
if (baseEv) {
const entry = {
...baseEv,
startDateInWeek: startInWeek,
endDateInWeek: endInWeek,
startIdx: sIdx,
endIdx: eIdx
}
weekEvents.set(pv.id, entry)
} }
} }
} }
} }
// No special preview overlay logic: we mutate events live during drag
const timeToMin = t => { const timeToMin = t => {
if (typeof t !== 'string') return 1e9 if (typeof t !== 'string') return 1e9
const m = t.match(/^(\d{2}):(\d{2})/) const m = t.match(/^(\d{2}):(\d{2})/)
@ -735,7 +902,7 @@ export class EventManager {
const bt = timeToMin(b.startTime) const bt = timeToMin(b.startTime)
if (at !== bt) return at - bt if (at !== bt) return at - bt
// Stable fallback by id // Stable fallback by id
return (a.id || 0) - (b.id || 0) return String(a.id).localeCompare(String(b.id))
}) })
const rowsLastEnd = [] const rowsLastEnd = []
@ -774,6 +941,7 @@ export class EventManager {
for (const w of spans) this.createOverlaySpan(overlay, w, weekEl) for (const w of spans) this.createOverlaySpan(overlay, w, weekEl)
} }
createOverlaySpan(overlay, w, weekEl) { createOverlaySpan(overlay, w, weekEl) {
const span = document.createElement('div') const span = document.createElement('div')
span.className = `event-span event-color-${w.colorId}` span.className = `event-span event-color-${w.colorId}`
@ -803,14 +971,14 @@ export class EventManager {
span.appendChild(right) span.appendChild(right)
// Pointer down handlers // Pointer down handlers
const onPointerDown = (mode, ev) => { const onPointerDown = (mode, ev) => {
// Prevent duplicate handling if we already have a drag state // Prevent duplicate handling if we already have a drag state
if (this.dragEventState) return if (this.dragEventState) return
// Don't prevent default immediately - let click events through // Don't prevent default immediately - let click events through
ev.stopPropagation() ev.stopPropagation()
const point = ev.touches ? ev.touches[0] : ev const point = ev
const hitAtStart = this.calendar.getDateUnderPointer(point.clientX, point.clientY) const hitAtStart = this.calendar.getDateUnderPointer(point.clientX, point.clientY)
this.dragEventState = { this.dragEventState = {
mode, mode,
id: w.id, id: w.id,
@ -821,7 +989,7 @@ export class EventManager {
pointerStartY: point.clientY, pointerStartY: point.clientY,
startDate: w.startDate, startDate: w.startDate,
endDate: w.endDate, endDate: w.endDate,
usingPointer: ev.type && ev.type.startsWith('pointer') usingPointer: ev.type && ev.type.startsWith('pointer')
} }
// compute anchor offset within the event based on where the pointer is // compute anchor offset within the event based on where the pointer is
const spanDays = daysInclusive(w.startDate, w.endDate) const spanDays = daysInclusive(w.startDate, w.endDate)
@ -856,13 +1024,7 @@ export class EventManager {
onPointerDown('move', e) onPointerDown('move', e)
}) })
// Touch support (for compatibility with older mobile browsers) // Pointer events cover mouse and touch
left.addEventListener('touchstart', e => onPointerDown('resize-left', e), { passive: false })
right.addEventListener('touchstart', e => onPointerDown('resize-right', e), { passive: false })
span.addEventListener('touchstart', e => {
if ((e.target).classList && (e.target).classList.contains('resize-handle')) return
onPointerDown('move', e)
}, { passive: false })
overlay.appendChild(span) overlay.appendChild(span)
} }
} }