vue #1
518
event-manager.js
518
event-manager.js
@ -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
|
||||||
@ -21,7 +20,6 @@ export class EventManager {
|
|||||||
|
|
||||||
// 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))
|
||||||
@ -123,70 +134,195 @@ export class EventManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate dates for repeating events
|
terminateRepeatSeriesAtIndex(baseEventId, terminateAtIndex) {
|
||||||
const startDate = new Date(fromLocalString(baseEventData.startDate))
|
// Find the base event and modify its repeat count to stop before the termination index
|
||||||
const endDate = new Date(fromLocalString(baseEventData.endDate))
|
for (const [, eventList] of this.events) {
|
||||||
const spanDays = Math.floor((endDate - startDate) / (24 * 60 * 60 * 1000))
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const maxOccurrences = repeatCount === 'unlimited' ? 104 : parseInt(repeatCount, 10) // Cap unlimited at ~2 years
|
moveRepeatSeries(baseEventId, newStartDateStr, newEndDateStr, mode) {
|
||||||
const dates = []
|
// 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
|
||||||
|
|
||||||
for (let i = 0; i < maxOccurrences; i++) {
|
let updatedStartDate, updatedEndDate
|
||||||
const currentStart = new Date(startDate)
|
if (mode === 'move') {
|
||||||
|
const spanDays = daysInclusive(oldStartDate, oldEndDate)
|
||||||
|
updatedStartDate = newStartDateStr
|
||||||
|
updatedEndDate = addDaysStr(newStartDateStr, spanDays - 1)
|
||||||
|
} else {
|
||||||
|
updatedStartDate = newStartDateStr
|
||||||
|
updatedEndDate = newEndDateStr
|
||||||
|
}
|
||||||
|
|
||||||
switch (repeat) {
|
// Update the base event with the new dates
|
||||||
|
const updated = {
|
||||||
|
...baseEvent,
|
||||||
|
startDate: updatedStartDate,
|
||||||
|
endDate: updatedEndDate
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateEventDatesAndReindex(baseEventId, updated)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateRepeatOccurrences(baseEvent, targetDateStr) {
|
||||||
|
if (!baseEvent.isRepeating || baseEvent.repeat === 'none') {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDate = new Date(fromLocalString(targetDateStr))
|
||||||
|
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
|
||||||
|
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
||||||
|
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
||||||
|
|
||||||
|
const occurrences = []
|
||||||
|
|
||||||
|
// Calculate how many intervals have passed since the base event
|
||||||
|
let intervalsPassed = 0
|
||||||
|
const timeDiff = targetDate - baseStartDate
|
||||||
|
|
||||||
|
switch (baseEvent.repeat) {
|
||||||
case 'daily':
|
case 'daily':
|
||||||
currentStart.setDate(startDate.getDate() + i)
|
intervalsPassed = Math.floor(timeDiff / (24 * 60 * 60 * 1000))
|
||||||
break
|
break
|
||||||
case 'weekly':
|
case 'weekly':
|
||||||
currentStart.setDate(startDate.getDate() + i * 7)
|
intervalsPassed = Math.floor(timeDiff / (7 * 24 * 60 * 60 * 1000))
|
||||||
break
|
break
|
||||||
case 'biweekly':
|
case 'biweekly':
|
||||||
currentStart.setDate(startDate.getDate() + i * 14)
|
intervalsPassed = Math.floor(timeDiff / (14 * 24 * 60 * 60 * 1000))
|
||||||
break
|
break
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
currentStart.setMonth(startDate.getMonth() + i)
|
intervalsPassed = Math.floor((targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
|
||||||
|
(targetDate.getMonth() - baseStartDate.getMonth()))
|
||||||
break
|
break
|
||||||
case 'yearly':
|
case 'yearly':
|
||||||
currentStart.setFullYear(startDate.getFullYear() + i)
|
intervalsPassed = targetDate.getFullYear() - baseStartDate.getFullYear()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check a few occurrences around the target date
|
||||||
|
for (let i = Math.max(0, intervalsPassed - 2); i <= intervalsPassed + 2; i++) {
|
||||||
|
const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||||
|
if (i >= maxOccurrences) break
|
||||||
|
|
||||||
|
const currentStart = new Date(baseStartDate)
|
||||||
|
|
||||||
|
switch (baseEvent.repeat) {
|
||||||
|
case 'daily':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + i)
|
||||||
|
break
|
||||||
|
case 'weekly':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + i * 7)
|
||||||
|
break
|
||||||
|
case 'biweekly':
|
||||||
|
currentStart.setDate(baseStartDate.getDate() + i * 14)
|
||||||
|
break
|
||||||
|
case 'monthly':
|
||||||
|
currentStart.setMonth(baseStartDate.getMonth() + i)
|
||||||
|
break
|
||||||
|
case 'yearly':
|
||||||
|
currentStart.setFullYear(baseStartDate.getFullYear() + i)
|
||||||
break
|
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,6 +499,22 @@ export class EventManager {
|
|||||||
})
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
} else if (this._dialogMode === 'edit' && this._editingEventId != null) {
|
} else if (this._dialogMode === 'edit' && this._editingEventId != null) {
|
||||||
|
const editingEvent = this.getEventById(this._editingEventId)
|
||||||
|
|
||||||
|
if (editingEvent && editingEvent.isRepeatOccurrence && editingEvent.repeatIndex > 0) {
|
||||||
|
// Editing a repeat occurrence that's not the first one
|
||||||
|
// Terminate the original series and create a new event
|
||||||
|
this.terminateRepeatSeriesAtIndex(editingEvent.baseEventId, editingEvent.repeatIndex)
|
||||||
|
this.createEventWithRepeat({
|
||||||
|
title: data.title.trim(),
|
||||||
|
startDate: editingEvent.startDate,
|
||||||
|
endDate: editingEvent.endDate,
|
||||||
|
colorId: data.colorId,
|
||||||
|
repeat: data.repeat,
|
||||||
|
repeatCount: data.repeatCount
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Normal event edit
|
||||||
this.applyEventEdit(this._editingEventId, {
|
this.applyEventEdit(this._editingEventId, {
|
||||||
title: data.title.trim(),
|
title: data.title.trim(),
|
||||||
colorId: data.colorId,
|
colorId: data.colorId,
|
||||||
@ -384,12 +522,21 @@ export class EventManager {
|
|||||||
repeatCount: data.repeatCount
|
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)
|
||||||
|
|
||||||
|
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 = []
|
const datesToCleanup = []
|
||||||
for (const [dateStr, eventList] of this.events) {
|
for (const [dateStr, eventList] of this.events) {
|
||||||
const eventIndex = eventList.findIndex(event => event.id === this._editingEventId)
|
const eventIndex = eventList.findIndex(event => event.id === this._editingEventId)
|
||||||
@ -405,6 +552,7 @@ export class EventManager {
|
|||||||
datesToCleanup.forEach(dateStr => this.events.delete(dateStr))
|
datesToCleanup.forEach(dateStr => this.events.delete(dateStr))
|
||||||
this.calendar.forceUpdateVisibleWeeks()
|
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,39 +620,21 @@ 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
|
||||||
}
|
}
|
||||||
@ -506,8 +642,7 @@ export class EventManager {
|
|||||||
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
|
||||||
@ -528,51 +661,65 @@ export class EventManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
const ev = this.getEventById(this.dragEventState.id)
|
||||||
this.dragPreview = null
|
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()
|
this.calendar.forceUpdateVisibleWeeks()
|
||||||
}
|
}
|
||||||
|
|
||||||
onEventDragEnd(e) {
|
|
||||||
if (!this.dragEventState) return
|
|
||||||
if (this.dragEventState.usingPointer && e && !(e.type && e.type.startsWith('pointer'))) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const st = this.dragEventState
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let startDateStr = this.dragPreview?.startDate
|
if (ev.isRepeatOccurrence) {
|
||||||
let endDateStr = this.dragPreview?.endDate
|
// Live-move: if first occurrence, shift entire series; else split once then move the new future series
|
||||||
|
if (ev.repeatIndex === 0) {
|
||||||
if (!startDateStr || !endDateStr) {
|
this.moveRepeatSeries(ev.baseEventId, s, en, this.dragEventState.mode)
|
||||||
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 {
|
} else {
|
||||||
startDateStr = st.startDate
|
// Split only once
|
||||||
endDateStr = st.endDate
|
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 {
|
||||||
const ev = this.getEventById(st.id)
|
// Non-repeating: mutate directly and repaint
|
||||||
if (ev) {
|
|
||||||
const updated = { ...ev }
|
const updated = { ...ev }
|
||||||
if (st.mode === 'move') {
|
if (this.dragEventState.mode === 'move') {
|
||||||
const spanDays = daysInclusive(ev.startDate, ev.endDate)
|
const spanDays = daysInclusive(ev.startDate, ev.endDate)
|
||||||
updated.startDate = startDateStr
|
updated.startDate = s
|
||||||
updated.endDate = addDaysStr(startDateStr, spanDays - 1)
|
updated.endDate = addDaysStr(s, spanDays - 1)
|
||||||
} else {
|
} else {
|
||||||
if (startDateStr <= endDateStr) {
|
if (s <= en) {
|
||||||
updated.startDate = startDateStr
|
updated.startDate = s
|
||||||
updated.endDate = endDateStr
|
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,48 +857,32 @@ export class EventManager {
|
|||||||
w.endIdx = cells.indexOf(cell)
|
w.endIdx = cells.indexOf(cell)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate repeat occurrences for this date
|
||||||
|
for (const baseEvent of allRepeatingEvents) {
|
||||||
|
const repeatOccurrences = this.generateRepeatOccurrences(baseEvent, dateStr)
|
||||||
|
for (const repeatEvent of repeatOccurrences) {
|
||||||
|
// Skip if this is the original occurrence (already added above)
|
||||||
|
if (repeatEvent.startDate === baseEvent.startDate) continue
|
||||||
|
|
||||||
|
if (!weekEvents.has(repeatEvent.id)) {
|
||||||
|
weekEvents.set(repeatEvent.id, {
|
||||||
|
...repeatEvent,
|
||||||
|
startDateInWeek: dateStr,
|
||||||
|
endDateInWeek: dateStr,
|
||||||
|
startIdx: cells.indexOf(cell),
|
||||||
|
endIdx: cells.indexOf(cell)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const w = weekEvents.get(repeatEvent.id)
|
||||||
|
w.endDateInWeek = dateStr
|
||||||
|
w.endIdx = cells.indexOf(cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If dragging, hide the original of the dragged event and inject preview if it intersects this week
|
// No special preview overlay logic: we mutate events live during drag
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeToMin = t => {
|
const timeToMin = t => {
|
||||||
if (typeof t !== 'string') return 1e9
|
if (typeof t !== 'string') return 1e9
|
||||||
@ -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}`
|
||||||
@ -809,7 +977,7 @@ export class EventManager {
|
|||||||
|
|
||||||
// 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,
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user