diff --git a/event-manager.js b/event-manager.js
index 7fb1c2e..085a8d0 100644
--- a/event-manager.js
+++ b/event-manager.js
@@ -11,7 +11,6 @@ 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
@@ -20,8 +19,7 @@ export class EventManager {
this.dragAnchor = null
// Event drag state
- this.dragEventState = null
- this.dragPreview = null
+ this.dragEventState = null
this.justDragged = false
this._eventDragMoved = false
this._installedEventDrag = false
@@ -101,16 +99,29 @@ export class EventManager {
// -------- 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.eventIdCounter++,
+ 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
+ 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))
@@ -122,71 +133,196 @@ export class EventManager {
this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
}
- this.calendar.forceUpdateVisibleWeeks()
+ this.calendar.forceUpdateVisibleWeeks()
+ return event.id
}
createEventWithRepeat(eventData) {
- const { repeat, repeatCount, ...baseEventData } = eventData
-
- if (repeat === 'none') {
- // Single event
- this.createEvent(baseEventData)
- return
+ // 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 []
}
- // Calculate dates for repeating events
- const startDate = new Date(fromLocalString(baseEventData.startDate))
- const endDate = new Date(fromLocalString(baseEventData.endDate))
- const spanDays = Math.floor((endDate - startDate) / (24 * 60 * 60 * 1000))
+ 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 maxOccurrences = repeatCount === 'unlimited' ? 104 : parseInt(repeatCount, 10) // Cap unlimited at ~2 years
- const dates = []
+ const occurrences = []
- for (let i = 0; i < maxOccurrences; i++) {
- const currentStart = new Date(startDate)
+ // 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
- switch (repeat) {
+ const currentStart = new Date(baseStartDate)
+
+ switch (baseEvent.repeat) {
case 'daily':
- currentStart.setDate(startDate.getDate() + i)
+ currentStart.setDate(baseStartDate.getDate() + i)
break
case 'weekly':
- currentStart.setDate(startDate.getDate() + i * 7)
+ currentStart.setDate(baseStartDate.getDate() + i * 7)
break
case 'biweekly':
- currentStart.setDate(startDate.getDate() + i * 14)
+ currentStart.setDate(baseStartDate.getDate() + i * 14)
break
case 'monthly':
- currentStart.setMonth(startDate.getMonth() + i)
+ currentStart.setMonth(baseStartDate.getMonth() + i)
break
case 'yearly':
- currentStart.setFullYear(startDate.getFullYear() + i)
+ currentStart.setFullYear(baseStartDate.getFullYear() + i)
break
}
const currentEnd = new Date(currentStart)
currentEnd.setDate(currentStart.getDate() + spanDays)
- dates.push({
- startDate: toLocalString(currentStart),
- endDate: toLocalString(currentEnd)
- })
+ // 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
+ })
+ }
}
- // Create events for all dates
- dates.forEach(({ startDate, endDate }) => {
- this.createEvent({
- ...baseEventData,
- startDate,
- endDate
- })
- })
+ 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
}
@@ -249,7 +385,11 @@ export class EventManager {
title: data.title.trim(),
colorId: data.colorId,
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,
endDate: updated.endDate,
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)) {
const ds = toLocalString(d)
@@ -310,20 +454,6 @@ export class EventManager {
-
-
-
${Array.from({ length: 8 }, (_, i) => `
@@ -343,16 +473,8 @@ export class EventManager {
this.eventForm = this.eventModal.querySelector('form.ec-form')
this.eventTitleInput = this.eventForm.elements['title']
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"]'))
- // 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
this.eventColorInputs.forEach(radio => {
radio.addEventListener('change', () => {
@@ -377,33 +499,59 @@ export class EventManager {
})
this.clearSelection()
} else if (this._dialogMode === 'edit' && this._editingEventId != null) {
- this.applyEventEdit(this._editingEventId, {
- title: data.title.trim(),
- colorId: data.colorId,
- repeat: data.repeat,
- repeatCount: data.repeatCount
- })
+ 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) {
- // Find and remove the event 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)
+ 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()
}
- // Clean up empty date entries
- datesToCleanup.forEach(dateStr => this.events.delete(dateStr))
- this.calendar.forceUpdateVisibleWeeks()
}
this.hideEventDialog()
if (this._dialogMode === 'create') this.clearSelection()
@@ -429,19 +577,25 @@ export class EventManager {
if (mode === 'create') {
this.eventTitleInput.value = ''
this.eventRepeatInput.value = 'none'
- this.eventRepeatCountInput.value = '5'
- this.eventRepeatCountRow.style.display = '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
- this.eventTitleInput.value = ev.title || ''
- this.eventRepeatInput.value = ev.repeat || 'none'
- this.eventRepeatCountInput.value = ev.repeatCount || '5'
- this.eventRepeatCountRow.style.display = (ev.repeat && ev.repeat !== 'none') ? 'block' : 'none'
- this.eventColorInputs.forEach(r => r.checked = Number(r.value) === (ev.colorId ?? 0))
+
+ // 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)
@@ -456,7 +610,7 @@ export class EventManager {
return {
title: this.eventTitleInput.value,
repeat: this.eventRepeatInput.value,
- repeatCount: this.eventRepeatCountInput.value,
+ repeatCount: 'unlimited', // Always unlimited
colorId
}
}
@@ -466,48 +620,29 @@ export class EventManager {
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
-
- const pt = e.touches ? e.touches[0] : 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) {
@@ -516,9 +651,7 @@ export class EventManager {
const distance = Math.sqrt(dx * dx + dy * dy)
const minDragDistance = 5 // pixels
- if (distance < minDragDistance) {
- return // Don't start dragging yet
- }
+ if (distance < minDragDistance) return
// Only prevent default when we actually start dragging
if (e && e.cancelable) e.preventDefault()
this._eventDragMoved = true
@@ -527,52 +660,66 @@ export class EventManager {
if (e && e.cancelable) e.preventDefault()
}
- 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.calendar.forceUpdateVisibleWeeks()
- }
+ const hit = pt ? this.calendar.getDateUnderPointer(pt.clientX, pt.clientY) : null
+ if (!hit || !hit.date) return
+ const [s, en] = this.computeTentativeRangeFromPointer(hit.date)
- onEventDragEnd(e) {
- if (!this.dragEventState) return
- if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) {
+ 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
}
-
- 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
+
+ // 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
}
}
- 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)
+ 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 {
- if (startDateStr <= endDateStr) {
- updated.startDate = startDateStr
- updated.endDate = endDateStr
+ // 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
}
}
@@ -587,10 +734,35 @@ export class EventManager {
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)
@@ -605,10 +777,8 @@ export class EventManager {
this._eventDragMoved = false
this.removeGlobalEventDragHandlers()
- // Only update visible weeks if we actually dragged
- if (this.justDragged) {
- this.calendar.forceUpdateVisibleWeeks()
- }
+ // 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) {
@@ -616,7 +786,7 @@ export class EventManager {
this.justDragged = false
}, 100)
}
- this.dragPreview = null
+ // no preview state to clear
}
computeTentativeRangeFromPointer(dropDateStr) {
@@ -656,9 +826,22 @@ export class EventManager {
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, {
@@ -674,49 +857,33 @@ export class EventManager {
w.endIdx = cells.indexOf(cell)
}
}
- }
-
- // If dragging, hide the original of the dragged event and inject preview if it intersects this week
- if (this.dragPreview && this.dragPreview.id != null) {
- const pv = this.dragPreview
- // Remove original entries of the dragged event for this week to prevent ghosts
- if (weekEvents.has(pv.id)) weekEvents.delete(pv.id)
- // Determine week range
- const weekStart = cells[0]?.dataset?.date
- const weekEnd = cells[cells.length - 1]?.dataset?.date
- if (weekStart && weekEnd) {
- const s = pv.startDate
- const e = pv.endDate
- // Intersect preview with this week
- const startInWeek = s <= weekEnd && e >= weekStart ? (s < weekStart ? weekStart : s) : null
- const endInWeek = s <= weekEnd && e >= weekStart ? (e > weekEnd ? weekEnd : e) : null
- if (startInWeek && endInWeek) {
- // Compute indices
- let sIdx = cells.findIndex(c => c.dataset.date === startInWeek)
- if (sIdx === -1) sIdx = cells.findIndex(c => c.dataset.date > startInWeek)
- 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)
+
+ // 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})/)
@@ -735,7 +902,7 @@ export class EventManager {
const bt = timeToMin(b.startTime)
if (at !== bt) return at - bt
// Stable fallback by id
- return (a.id || 0) - (b.id || 0)
+ return String(a.id).localeCompare(String(b.id))
})
const rowsLastEnd = []
@@ -774,6 +941,7 @@ export class EventManager {
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}`
@@ -803,14 +971,14 @@ export class EventManager {
span.appendChild(right)
// Pointer down handlers
- const onPointerDown = (mode, ev) => {
+ 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.touches ? ev.touches[0] : ev
- const hitAtStart = this.calendar.getDateUnderPointer(point.clientX, point.clientY)
+ const point = ev
+ const hitAtStart = this.calendar.getDateUnderPointer(point.clientX, point.clientY)
this.dragEventState = {
mode,
id: w.id,
@@ -821,7 +989,7 @@ export class EventManager {
pointerStartY: point.clientY,
startDate: w.startDate,
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
const spanDays = daysInclusive(w.startDate, w.endDate)
@@ -856,13 +1024,7 @@ export class EventManager {
onPointerDown('move', e)
})
- // Touch support (for compatibility with older mobile browsers)
- 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 })
+ // Pointer events cover mouse and touch
overlay.appendChild(span)
}
}