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) } }