From 07b22fa88596bdcd077c76f816ffed9dc14f94a1 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 22 Aug 2025 12:06:04 -0600 Subject: [PATCH] Corrections on store and repeats. --- src/components/EventDialog.vue | 93 ++-------- src/components/EventOverlay.vue | 317 +++++++++++++++++--------------- src/stores/CalendarStore.js | 285 ++++++++++++++-------------- 3 files changed, 322 insertions(+), 373 deletions(-) diff --git a/src/components/EventDialog.vue b/src/components/EventDialog.vue index a2f7e31..288d63d 100644 --- a/src/components/EventDialog.vue +++ b/src/components/EventDialog.vue @@ -19,7 +19,7 @@ const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occur const title = ref('') const recurrenceEnabled = ref(false) const recurrenceInterval = ref(1) // N in "Every N weeks/months" -const recurrenceFrequency = ref('weeks') // 'weeks' | 'months' | 'years' +const recurrenceFrequency = ref('weeks') // 'weeks' | 'months' const recurrenceWeekdays = ref([false, false, false, false, false, false, false]) const recurrenceOccurrences = ref(0) // 0 = unlimited const colorId = ref(0) @@ -42,31 +42,11 @@ const fallbackWeekdays = computed(() => { return fallback }) -function preventFocusOnMouseDown(event) { - // Prevent focus when clicking with mouse, but allow keyboard navigation - event.preventDefault() -} - -// Bridge legacy repeat API (store still expects repeat & repeatWeekdays) +// Repeat mapping uses 'weeks' | 'months' | 'none' directly (legacy 'weekly'/'monthly' accepted on load) const repeat = computed({ get() { if (!recurrenceEnabled.value) return 'none' - if (recurrenceFrequency.value === 'weeks') { - if (recurrenceInterval.value === 1) return 'weekly' - if (recurrenceInterval.value === 2) return 'biweekly' - // Fallback map >2 to weekly (future: custom) - return 'weekly' - } else if (recurrenceFrequency.value === 'months') { - if (recurrenceInterval.value === 1) return 'monthly' - if (recurrenceInterval.value === 12) return 'yearly' - // Fallback map >1 to monthly - return 'monthly' - } else { - // years (map to yearly via 12 * interval months) - if (recurrenceInterval.value === 1) return 'yearly' - // Multi-year -> treat as yearly (future: custom) - return 'yearly' - } + return recurrenceFrequency.value // 'weeks' | 'months' }, set(val) { if (val === 'none') { @@ -74,27 +54,8 @@ const repeat = computed({ return } recurrenceEnabled.value = true - switch (val) { - case 'weekly': - recurrenceFrequency.value = 'weeks' - recurrenceInterval.value = 1 - break - case 'biweekly': - recurrenceFrequency.value = 'weeks' - recurrenceInterval.value = 2 - break - case 'monthly': - recurrenceFrequency.value = 'months' - recurrenceInterval.value = 1 - break - case 'yearly': - recurrenceFrequency.value = 'years' - recurrenceInterval.value = 1 - break - default: - recurrenceFrequency.value = 'weeks' - recurrenceInterval.value = 1 - } + if (val === 'weeks' || val === 'weekly') recurrenceFrequency.value = 'weeks' + else if (val === 'months' || val === 'monthly') recurrenceFrequency.value = 'months' }, }) @@ -153,6 +114,7 @@ function openCreateDialog() { endDate: props.selection.end, colorId: colorId.value, repeat: repeat.value, + repeatInterval: recurrenceInterval.value, repeatCount: recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value), repeatWeekdays: buildStoreWeekdayPattern(), @@ -204,6 +166,7 @@ function openEditDialog(eventInstanceId) { title.value = event.title loadWeekdayPatternFromStore(event.repeatWeekdays) repeat.value = event.repeat // triggers setter mapping into recurrence state + if (event.repeatInterval) recurrenceInterval.value = event.repeatInterval // Map repeatCount const rc = event.repeatCount ?? 'unlimited' recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0 @@ -244,6 +207,7 @@ function updateEventInStore() { event.title = title.value event.colorId = colorId.value event.repeat = repeat.value + event.repeatInterval = recurrenceInterval.value event.repeatWeekdays = buildStoreWeekdayPattern() event.repeatCount = recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value) @@ -304,7 +268,7 @@ watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => { watch( recurrenceWeekdays, () => { - if (editingEventId.value && showDialog.value && repeat.value === 'weekly') updateEventInStore() + if (editingEventId.value && showDialog.value && repeat.value === 'weeks') updateEventInStore() }, { deep: true }, ) @@ -396,12 +360,6 @@ const finalOccurrenceDate = computed(() => { const d = new Date(start) d.setMonth(d.getMonth() + monthsToAdd) return d - } else { - // years - const yearsToAdd = recurrenceInterval.value * (count - 1) - const d = new Date(start) - d.setFullYear(d.getFullYear() + yearsToAdd) - return d } }) @@ -427,21 +385,15 @@ const formattedFinalOccurrence = computed(() => { const recurrenceSummary = computed(() => { if (!recurrenceEnabled.value) return 'Does not recur' - const unit = recurrenceFrequency.value // weeks | months | years (plural) - const singular = unit.slice(0, -1) - const unitary = { weeks: 'Weekly', months: 'Monthly', years: 'Annually' } - let base = - recurrenceInterval.value > 1 ? `Every ${recurrenceInterval.value} ${unit}` : unitary[unit] if (recurrenceFrequency.value === 'weeks') { - const sel = weekdays.filter((_, i) => recurrenceWeekdays.value[i]) - if (sel.length) base += ' on ' + sel.join(', ') + return recurrenceInterval.value === 1 ? 'Weekly' : `Every ${recurrenceInterval.value} weeks` } - base += - ' ยท ' + - (recurrenceOccurrences.value === 0 - ? 'no end' - : `${recurrenceOccurrences.value} ${recurrenceOccurrences.value === 1 ? 'time' : 'times'}`) - return base + // months frequency + if (recurrenceInterval.value % 12 === 0) { + const years = recurrenceInterval.value / 12 + return years === 1 ? 'Annually' : `Every ${years} years` + } + return recurrenceInterval.value === 1 ? 'Monthly' : `Every ${recurrenceInterval.value} months` }) @@ -476,15 +428,7 @@ const recurrenceSummary = computed(() => { Repeat - {{ - recurrenceInterval === 1 - ? recurrenceFrequency === 'months' - ? 'Monthly' - : recurrenceFrequency === 'years' - ? 'Annually' - : 'Every week' - : `Every ${recurrenceInterval} ${recurrenceFrequency}` - }} + {{ recurrenceSummary }} @@ -504,7 +448,6 @@ const recurrenceSummary = computed(() => { { font-size: 0.75rem; border: 1px solid var(--input-border); background: var(--panel-alt); + color: var(--ink); border-radius: 0.45rem; transition: border-color 0.18s ease, @@ -806,6 +750,7 @@ const recurrenceSummary = computed(() => { outline: none; border-color: var(--input-focus); background: var(--panel-accent); + color: var(--ink); box-shadow: 0 0 0 1px var(--input-focus), 0 0 0 4px rgba(37, 99, 235, 0.15); diff --git a/src/components/EventOverlay.vue b/src/components/EventOverlay.vue index 9180f7b..0cbaa3a 100644 --- a/src/components/EventOverlay.vue +++ b/src/components/EventOverlay.vue @@ -3,22 +3,22 @@
- {{ span.title }} -
{{ span.title }} +
-
@@ -33,8 +33,8 @@ import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/uti const props = defineProps({ week: { type: Object, - required: true - } + required: true, + }, }) const emit = defineEmits(['event-click']) @@ -47,44 +47,51 @@ const justDragged = ref(false) // Generate repeat occurrences for a specific date function generateRepeatOccurrencesForDate(targetDateStr) { const occurrences = [] - + // Get all events from the store and check for repeating ones for (const [, eventList] of store.events) { for (const baseEvent of eventList) { if (!baseEvent.isRepeating || baseEvent.repeat === 'none') { continue } - + 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)) - - if (baseEvent.repeat === 'weekly') { + + if (baseEvent.repeat === 'weeks') { const repeatWeekdays = baseEvent.repeatWeekdays const targetWeekday = targetDate.getDay() if (!repeatWeekdays[targetWeekday]) continue if (targetDate < baseStartDate) continue - const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) + const maxOccurrences = + baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) + const interval = baseEvent.repeatInterval || 1 if (maxOccurrences === 0) continue // Count occurrences from start up to (and including) target let occIdx = 0 + // Determine the week distance from baseStartDate to targetDate + const msPerDay = 24 * 60 * 60 * 1000 + const daysDiff = Math.floor((targetDate - baseStartDate) / msPerDay) + const weeksDiff = Math.floor(daysDiff / 7) + if (weeksDiff % interval !== 0) continue + // Count occurrences only among valid weeks and selected weekdays const cursor = new Date(baseStartDate) while (cursor < targetDate && occIdx < maxOccurrences) { - if (repeatWeekdays[cursor.getDay()]) occIdx++ + const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay) + const cWeeksDiff = Math.floor(cDaysDiff / 7) + if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++ cursor.setDate(cursor.getDate() + 1) } - // If target itself is the base start and it's selected, occIdx == 0 => base event (skip) - if (cursor.getTime() === targetDate.getTime()) { - // We haven't advanced past target, so if its weekday is selected and this is the first occurrence, skip - if (occIdx === 0) continue - } else { - // We advanced past target; if target weekday is selected this is the next occurrence index already counted, so decrement for proper index - // Ensure occIdx corresponds to this occurrence (already counted earlier occurrences only) + if (targetDate.getTime() === baseStartDate.getTime()) { + // skip base occurrence + continue } if (occIdx >= maxOccurrences) continue const occStart = new Date(targetDate) - const occEnd = new Date(occStart); occEnd.setDate(occStart.getDate() + spanDays) + const occEnd = new Date(occStart) + occEnd.setDate(occStart.getDate() + spanDays) const occStartStr = toLocalString(occStart) const occEndStr = toLocalString(occEnd) occurrences.push({ @@ -93,71 +100,51 @@ function generateRepeatOccurrencesForDate(targetDateStr) { startDate: occStartStr, endDate: occEndStr, isRepeatOccurrence: true, - repeatIndex: occIdx + repeatIndex: occIdx, }) continue } else { - // Handle other repeat types (biweekly, monthly, yearly) + // Handle other repeat types (months) let intervalsPassed = 0 const timeDiff = targetDate - baseStartDate - - switch (baseEvent.repeat) { - 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 + if (baseEvent.repeat === 'months') { + intervalsPassed = + (targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 + + (targetDate.getMonth() - baseStartDate.getMonth()) + } else { + continue } - + const interval = baseEvent.repeatInterval || 1 + if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue + // 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 'biweekly': - currentStart.setDate(baseStartDate.getDate() + i * 14) - break - case 'monthly': - currentStart.setMonth(baseStartDate.getMonth() + i) - break - case 'yearly': - currentStart.setFullYear(baseStartDate.getFullYear() + i) - break - } - - const currentEnd = new Date(currentStart) - currentEnd.setDate(currentStart.getDate() + spanDays) - - // Check if this occurrence intersects with the target date - const currentStartStr = toLocalString(currentStart) - const currentEndStr = toLocalString(currentEnd) - - if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) { - // Skip the original occurrence (i === 0) since it's already in the base events - if (i === 0) continue - - occurrences.push({ - ...baseEvent, - id: `${baseEvent.id}_repeat_${i}`, - startDate: currentStartStr, - endDate: currentEndStr, - isRepeatOccurrence: true, - repeatIndex: i - }) - } + const maxOccurrences = + baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) + if (maxOccurrences === 0) continue + const i = intervalsPassed + if (i >= maxOccurrences) continue + // Skip base occurrence + if (i === 0) continue + const currentStart = new Date(baseStartDate) + currentStart.setMonth(baseStartDate.getMonth() + i) + const currentEnd = new Date(currentStart) + currentEnd.setDate(currentStart.getDate() + spanDays) + 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, + }) } } } } - + return occurrences } @@ -180,45 +167,51 @@ function handleEventClick(span) { function handleEventPointerDown(span, event) { // Don't start drag if clicking on resize handle if (event.target.classList.contains('resize-handle')) return - + event.stopPropagation() // Do not preventDefault here to allow click unless drag threshold is passed - + // Get the date under the pointer const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget) const anchorDate = hit ? hit.date : span.startDate - - startLocalDrag({ - id: span.id, - mode: 'move', - pointerStartX: event.clientX, - pointerStartY: event.clientY, - anchorDate, - startDate: span.startDate, - endDate: span.endDate - }, event) + + startLocalDrag( + { + id: span.id, + mode: 'move', + pointerStartX: event.clientX, + pointerStartY: event.clientY, + anchorDate, + startDate: span.startDate, + endDate: span.endDate, + }, + event, + ) } // Handle resize handle pointer down function handleResizePointerDown(span, mode, event) { event.stopPropagation() // Start drag from the current edge; anchorDate not needed for resize - startLocalDrag({ - id: span.id, - mode, - pointerStartX: event.clientX, - pointerStartY: event.clientY, - anchorDate: null, - startDate: span.startDate, - endDate: span.endDate - }, event) + startLocalDrag( + { + id: span.id, + mode, + pointerStartX: event.clientX, + pointerStartY: event.clientY, + anchorDate: null, + startDate: span.startDate, + endDate: span.endDate, + }, + event, + ) } // Get date under pointer coordinates function getDateUnderPointer(clientX, clientY, targetEl) { // First try to find a day cell directly under the pointer let element = document.elementFromPoint(clientX, clientY) - + // If we hit an event element, temporarily hide it and try again const hiddenElements = [] while (element && element.classList.contains('event-span')) { @@ -226,17 +219,17 @@ function getDateUnderPointer(clientX, clientY, targetEl) { hiddenElements.push(element) element = document.elementFromPoint(clientX, clientY) } - + // Restore pointer events for hidden elements - hiddenElements.forEach(el => el.style.pointerEvents = 'auto') - + hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto')) + if (element) { // Look for a day cell with data-date attribute const dayElement = element.closest('[data-date]') if (dayElement && dayElement.dataset.date) { return { date: dayElement.dataset.date } } - + // Also check if we're over a week element and can calculate position const weekElement = element.closest('.week-row') if (weekElement) { @@ -244,7 +237,7 @@ function getDateUnderPointer(clientX, clientY, targetEl) { const relativeX = clientX - rect.left const dayWidth = rect.width / 7 const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth))) - + const daysGrid = weekElement.querySelector('.days-grid') if (daysGrid && daysGrid.children[dayIndex]) { const dayEl = daysGrid.children[dayIndex] @@ -262,7 +255,7 @@ function getDateUnderPointer(clientX, clientY, targetEl) { const allWeekElements = document.querySelectorAll('.week-row') let bestWeek = null let bestDistance = Infinity - + for (const week of allWeekElements) { const rect = week.getBoundingClientRect() if (clientY >= rect.top && clientY <= rect.bottom) { @@ -273,13 +266,13 @@ function getDateUnderPointer(clientX, clientY, targetEl) { } } } - + if (bestWeek) { const rect = bestWeek.getBoundingClientRect() const relativeX = clientX - rect.left const dayWidth = rect.width / 7 const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth))) - + const daysGrid = bestWeek.querySelector('.days-grid') if (daysGrid && daysGrid.children[dayIndex]) { const dayEl = daysGrid.children[dayIndex] @@ -289,16 +282,16 @@ function getDateUnderPointer(clientX, clientY, targetEl) { } return null } - + const rect = weekElement.getBoundingClientRect() const relativeX = clientX - rect.left const dayWidth = rect.width / 7 const dayIndex = Math.floor(Math.max(0, Math.min(6, relativeX / dayWidth))) - + if (props.week.days[dayIndex]) { return { date: props.week.days[dayIndex].date } } - + return null } @@ -316,21 +309,21 @@ function startLocalDrag(init, evt) { ...init, anchorOffset, originSpanDays: spanDays, - eventMoved: false + eventMoved: false, } // Capture pointer events globally if (evt.currentTarget && evt.pointerId !== undefined) { - try { + try { evt.currentTarget.setPointerCapture(evt.pointerId) } catch (e) { console.warn('Could not set pointer capture:', e) } } - + // Prevent default to avoid text selection and other interference evt.preventDefault() - + window.addEventListener('pointermove', onDragPointerMove, { passive: false }) window.addEventListener('pointerup', onDragPointerUp, { passive: false }) window.addEventListener('pointercancel', onDragPointerUp, { passive: false }) @@ -347,10 +340,10 @@ function onDragPointerMove(e) { const hitEl = document.elementFromPoint(e.clientX, e.clientY) const hit = getDateUnderPointer(e.clientX, e.clientY, hitEl) - + // If we can't find a date, don't update the range but keep the drag active if (!hit || !hit.date) return - + const [ns, ne] = computeTentativeRangeFromPointer(st, hit.date) if (!ns || !ne) return applyRangeDuringDrag(st, ns, ne) @@ -359,26 +352,28 @@ function onDragPointerMove(e) { function onDragPointerUp(e) { const st = dragState.value if (!st) return - + // Release pointer capture if it was set if (e.target && e.pointerId !== undefined) { - try { - e.target.releasePointerCapture(e.pointerId) + try { + e.target.releasePointerCapture(e.pointerId) } catch (err) { // Ignore errors - capture might not have been set } } - + const moved = !!st.eventMoved dragState.value = null - + window.removeEventListener('pointermove', onDragPointerMove) window.removeEventListener('pointerup', onDragPointerUp) window.removeEventListener('pointercancel', onDragPointerUp) - + if (moved) { justDragged.value = true - setTimeout(() => { justDragged.value = false }, 120) + setTimeout(() => { + justDragged.value = false + }, 120) } } @@ -407,20 +402,40 @@ function normalizeDateOrder(aStr, bStr) { } function applyRangeDuringDrag(st, startDate, endDate) { - const ev = store.getEventById(st.id) + let ev = store.getEventById(st.id) + let isRepeatOccurrence = false + let baseId = st.id + let repeatIndex = 0 + let grabbedWeekday = null + + // If not found (repeat occurrences aren't stored) parse synthetic id + if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) { + const [bid, suffix] = st.id.split('_repeat_') + baseId = bid + ev = store.getEventById(baseId) + if (ev) { + const parts = suffix.split('_') + repeatIndex = parseInt(parts[0], 10) || 0 + grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null + isRepeatOccurrence = repeatIndex >= 0 + } + } + if (!ev) return - if (ev.isRepeatOccurrence) { - const idParts = String(st.id).split('_repeat_') - const baseId = idParts[0] - const repeatParts = idParts[1].split('_') - const repeatIndex = parseInt(repeatParts[0], 10) || 0 - const grabbedWeekday = repeatParts.length > 1 ? parseInt(repeatParts[1], 10) : null - + + const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move' + if (isRepeatOccurrence) { if (repeatIndex === 0) { - store.setEventRange(baseId, startDate, endDate) + store.setEventRange(baseId, startDate, endDate, { mode }) } else { if (!st.splitNewBaseId) { - const newId = store.splitRepeatSeries(baseId, repeatIndex, startDate, endDate, grabbedWeekday) + const newId = store.splitRepeatSeries( + baseId, + repeatIndex, + startDate, + endDate, + grabbedWeekday, + ) if (newId) { st.splitNewBaseId = newId st.id = newId @@ -428,11 +443,11 @@ function applyRangeDuringDrag(st, startDate, endDate) { st.endDate = endDate } } else { - store.setEventRange(st.splitNewBaseId, startDate, endDate) + store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode }) } } } else { - store.setEventRange(st.id, startDate, endDate) + store.setEventRange(st.id, startDate, endDate, { mode }) } } @@ -440,31 +455,31 @@ function applyRangeDuringDrag(st, startDate, endDate) { const eventSpans = computed(() => { const spans = [] const weekEvents = new Map() - + // Collect events from all days in this week, including repeat occurrences props.week.days.forEach((day, dayIndex) => { // Get base events for this day - day.events.forEach(event => { + day.events.forEach((event) => { if (!weekEvents.has(event.id)) { weekEvents.set(event.id, { ...event, startIdx: dayIndex, - endIdx: dayIndex + endIdx: dayIndex, }) } else { const existing = weekEvents.get(event.id) existing.endIdx = dayIndex } }) - + // Generate repeat occurrences for this day const repeatOccurrences = generateRepeatOccurrencesForDate(day.date) - repeatOccurrences.forEach(event => { + repeatOccurrences.forEach((event) => { if (!weekEvents.has(event.id)) { weekEvents.set(event.id, { ...event, startIdx: dayIndex, - endIdx: dayIndex + endIdx: dayIndex, }) } else { const existing = weekEvents.get(event.id) @@ -472,7 +487,7 @@ const eventSpans = computed(() => { } }) }) - + // Convert to array and sort const eventArray = Array.from(weekEvents.values()) eventArray.sort((a, b) => { @@ -480,22 +495,22 @@ const eventSpans = computed(() => { const spanA = a.endIdx - a.startIdx const spanB = b.endIdx - b.startIdx if (spanA !== spanB) return spanB - spanA - + // Then by start position if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx - + // Then by start time if available const timeA = a.startTime ? timeToMinutes(a.startTime) : 0 const timeB = b.startTime ? timeToMinutes(b.startTime) : 0 if (timeA !== timeB) return timeA - timeB - + // Fallback to ID return String(a.id).localeCompare(String(b.id)) }) - + // Assign rows to avoid overlaps const rowsLastEnd = [] - eventArray.forEach(event => { + eventArray.forEach((event) => { let placedRow = 0 while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) { placedRow++ @@ -506,7 +521,7 @@ const eventSpans = computed(() => { rowsLastEnd[placedRow] = event.endIdx event.row = placedRow + 1 }) - + return eventArray }) diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js index fb6a0e5..c92e893 100644 --- a/src/stores/CalendarStore.js +++ b/src/stores/CalendarStore.js @@ -13,14 +13,14 @@ export const useCalendarStore = defineStore('calendar', { config: { select_days: 1000, min_year: MIN_YEAR, - max_year: MAX_YEAR - } + max_year: MAX_YEAR, + }, }), getters: { // Basic configuration getters minYear: () => MIN_YEAR, - maxYear: () => MAX_YEAR + maxYear: () => MAX_YEAR, }, actions: { @@ -49,13 +49,20 @@ export const useCalendarStore = defineStore('calendar', { 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, - repeat: eventData.repeat || 'none', + colorId: + eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), + startTime: singleDay ? eventData.startTime || '09:00' : null, + durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, + repeat: + (eventData.repeat === 'weekly' + ? 'weeks' + : eventData.repeat === 'monthly' + ? 'months' + : eventData.repeat) || 'none', + repeatInterval: eventData.repeatInterval || 1, repeatCount: eventData.repeatCount || 'unlimited', repeatWeekdays: eventData.repeatWeekdays, - isRepeating: (eventData.repeat && eventData.repeat !== 'none') + isRepeating: eventData.repeat && eventData.repeat !== 'none', } const startDate = new Date(fromLocalString(event.startDate)) @@ -68,12 +75,13 @@ export const useCalendarStore = defineStore('calendar', { } this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate }) } + // No physical expansion; repeats are virtual return event.id }, getEventById(id) { 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 } return null @@ -110,7 +118,7 @@ export const useCalendarStore = defineStore('calendar', { deleteEvent(eventId) { const datesToCleanup = [] for (const [dateStr, eventList] of this.events) { - const eventIndex = eventList.findIndex(event => event.id === eventId) + const eventIndex = eventList.findIndex((event) => event.id === eventId) if (eventIndex !== -1) { eventList.splice(eventIndex, 1) if (eventList.length === 0) { @@ -118,17 +126,21 @@ export const useCalendarStore = defineStore('calendar', { } } } - datesToCleanup.forEach(dateStr => this.events.delete(dateStr)) + datesToCleanup.forEach((dateStr) => this.events.delete(dateStr)) }, deleteSingleOccurrence(ctx) { - const { baseId, occurrenceIndex, weekday } = ctx + const { baseId, occurrenceIndex } = ctx const base = this.getEventById(baseId) if (!base || base.repeat !== 'weekly') return + if (!base || base.repeat !== 'weeks') return // Strategy: clone pattern into two: reduce base repeatCount to exclude this occurrence; create new series for remainder excluding this one // Simpler: convert base to explicit exception by creating a one-off non-repeating event? For now: create exception by inserting a blocking dummy? Instead just split by shifting repeatCount logic: decrement repeatCount and create a new series starting after this single occurrence. // Implementation (approx): terminate at occurrenceIndex; create a new series starting next occurrence with remaining occurrences. - const remaining = base.repeatCount === 'unlimited' ? 'unlimited' : String(Math.max(0, parseInt(base.repeatCount,10) - (occurrenceIndex+1))) + const remaining = + base.repeatCount === 'unlimited' + ? 'unlimited' + : String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1))) this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) if (remaining === '0') return // Find date of next occurrence @@ -145,9 +157,9 @@ export const useCalendarStore = defineStore('calendar', { startDate: nextStartStr, endDate: nextStartStr, colorId: base.colorId, - repeat: 'weekly', + repeat: 'weeks', repeatCount: remaining, - repeatWeekdays: base.repeatWeekdays + repeatWeekdays: base.repeatWeekdays, }) }, @@ -164,21 +176,19 @@ export const useCalendarStore = defineStore('calendar', { const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000)) let newStart = null - if (base.repeat === 'weekly' && base.repeatWeekdays) { + if (base.repeat === 'weeks' && base.repeatWeekdays) { const probe = new Date(oldStart) - for (let i = 0; i < 14; i++) { // search ahead up to 2 weeks + for (let i = 0; i < 14; i++) { + // search ahead up to 2 weeks probe.setDate(probe.getDate() + 1) - if (base.repeatWeekdays[probe.getDay()]) { newStart = new Date(probe); break } + if (base.repeatWeekdays[probe.getDay()]) { + newStart = new Date(probe) + break + } } - } else if (base.repeat === 'biweekly') { - newStart = new Date(oldStart) - newStart.setDate(newStart.getDate() + 14) - } else if (base.repeat === 'monthly') { + } else if (base.repeat === 'months') { newStart = new Date(oldStart) newStart.setMonth(newStart.getMonth() + 1) - } else if (base.repeat === 'yearly') { - newStart = new Date(oldStart) - newStart.setFullYear(newStart.getFullYear() + 1) } else { // Unknown pattern: delete entire series this.deleteEvent(baseId) @@ -207,63 +217,7 @@ export const useCalendarStore = defineStore('calendar', { newEnd.setDate(newEnd.getDate() + spanDays) base.startDate = toLocalString(newStart) base.endDate = toLocalString(newEnd) - // Reindex across map - this._removeEventFromAllDatesById(baseId) - this._addEventToDateRangeWithId(baseId, base, base.startDate, base.endDate) - }, - - updateEvent(eventId, updates) { - // Remove event from current dates - for (const [dateStr, eventList] of this.events) { - const index = eventList.findIndex(e => e.id === eventId) - if (index !== -1) { - const event = eventList[index] - eventList.splice(index, 1) - if (eventList.length === 0) { - this.events.delete(dateStr) - } - - // Create updated event and add to new date range - const updatedEvent = { ...event, ...updates } - this._addEventToDateRange(updatedEvent) - return - } - } - }, - - // Minimal public API for component-driven drag - setEventRange(eventId, startDate, endDate) { - const snapshot = this._snapshotBaseEvent(eventId) - if (!snapshot) return - - // Calculate rotated weekdays for weekly repeats - if (snapshot.repeat === 'weekly' && snapshot.repeatWeekdays) { - const originalStartDate = new Date(fromLocalString(snapshot.startDate)) - const newStartDate = new Date(fromLocalString(startDate)) - const dayShift = newStartDate.getDay() - originalStartDate.getDay() - - if (dayShift !== 0) { - const rotatedWeekdays = [false, false, false, false, false, false, false] - - for (let i = 0; i < 7; i++) { - if (snapshot.repeatWeekdays[i]) { - let newDay = (i + dayShift) % 7 - if (newDay < 0) newDay += 7 - rotatedWeekdays[newDay] = true - } - } - snapshot.repeatWeekdays = rotatedWeekdays - } - } - - this._removeEventFromAllDatesById(eventId) - this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate) - }, - - splitRepeatSeries(baseId, index, startDate, endDate, grabbedWeekday = null) { - const base = this.getEventById(baseId) - if (!base) return null - + // old occurrence expansion removed (series handled differently now) const originalRepeatCount = base.repeatCount // Always cap original series at the split occurrence index (occurrences 0..index-1) // Keep its weekday pattern unchanged. @@ -284,12 +238,12 @@ export const useCalendarStore = defineStore('calendar', { // Handle weekdays for weekly repeats let newRepeatWeekdays = base.repeatWeekdays - if (base.repeat === 'weekly' && base.repeatWeekdays) { + if (base.repeat === 'weeks' && base.repeatWeekdays) { const newStartDate = new Date(fromLocalString(startDate)) let dayShift = 0 if (grabbedWeekday != null) { // Rotate so that the grabbed weekday maps to the new start weekday - dayShift = newStartDate.getDay() - grabbedWeekday + dayShift = newStartDate.getDay() - grabbedWeekday } else { // Fallback: rotate by difference between new and original start weekday const originalStartDate = new Date(fromLocalString(base.startDate)) @@ -315,16 +269,15 @@ export const useCalendarStore = defineStore('calendar', { colorId: base.colorId, repeat: base.repeat, repeatCount: newRepeatCount, - repeatWeekdays: newRepeatWeekdays + repeatWeekdays: newRepeatWeekdays, }) return newId }, - _snapshotBaseEvent(eventId) { // Return a shallow snapshot of any instance for metadata for (const [, eventList] of this.events) { - const e = eventList.find(x => x.id === eventId) + const e = eventList.find((x) => x.id === eventId) if (e) return { ...e } } return null @@ -350,7 +303,7 @@ export const useCalendarStore = defineStore('calendar', { id: eventId, startDate, endDate, - isSpanning: multi + isSpanning: multi, } // Normalize single-day time fields if (!multi) { @@ -369,6 +322,98 @@ export const useCalendarStore = defineStore('calendar', { } }, + // expandRepeats removed: no physical occurrence expansion + + // Adjust start/end range of a base event (non-generated) and reindex occurrences + setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { + const snapshot = this._findEventInAnyList(eventId) + if (!snapshot) return + // Calculate current duration in days (inclusive) + const prevStart = new Date(fromLocalString(snapshot.startDate)) + const prevEnd = new Date(fromLocalString(snapshot.endDate)) + const prevDurationDays = Math.max( + 0, + Math.round((prevEnd - prevStart) / (24 * 60 * 60 * 1000)), + ) + + const newStart = new Date(fromLocalString(newStartStr)) + const newEnd = new Date(fromLocalString(newEndStr)) + const proposedDurationDays = Math.max( + 0, + Math.round((newEnd - newStart) / (24 * 60 * 60 * 1000)), + ) + + let finalDurationDays = prevDurationDays + if (mode === 'resize-left' || mode === 'resize-right') { + finalDurationDays = proposedDurationDays + } + + snapshot.startDate = newStartStr + snapshot.endDate = toLocalString( + new Date( + new Date(fromLocalString(newStartStr)).setDate( + new Date(fromLocalString(newStartStr)).getDate() + finalDurationDays, + ), + ), + ) + // Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift + if ( + mode === 'move' && + snapshot.isRepeating && + snapshot.repeat === 'weeks' && + Array.isArray(snapshot.repeatWeekdays) + ) { + const oldDow = prevStart.getDay() + const newDow = newStart.getDay() + const shift = newDow - oldDow + if (shift !== 0) { + const rotated = [false, false, false, false, false, false, false] + for (let i = 0; i < 7; i++) { + if (snapshot.repeatWeekdays[i]) { + let ni = (i + shift) % 7 + if (ni < 0) ni += 7 + rotated[ni] = true + } + } + snapshot.repeatWeekdays = rotated + } + } + // Reindex + this._removeEventFromAllDatesById(eventId) + this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate) + // no expansion + }, + + // Split a repeating series at a given occurrence index; returns new series id + splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) { + const base = this._findEventInAnyList(baseId) + if (!base || !base.isRepeating) return null + // Capture original repeatCount BEFORE truncation + const originalCountRaw = base.repeatCount + // Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1) + this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) + // Compute new series repeatCount (remaining occurrences starting at occurrenceIndex) + let newSeriesCount = 'unlimited' + if (originalCountRaw !== 'unlimited') { + const originalNum = parseInt(originalCountRaw, 10) + if (!isNaN(originalNum)) { + const remaining = originalNum - occurrenceIndex + newSeriesCount = String(Math.max(1, remaining)) + } + } + const newId = this.createEvent({ + title: base.title, + startDate: newStartStr, + endDate: newEndStr, + colorId: base.colorId, + repeat: base.repeat, + repeatInterval: base.repeatInterval, + repeatCount: newSeriesCount, + repeatWeekdays: base.repeatWeekdays, + }) + return newId + }, + _reindexBaseEvent(eventId, snapshot, startDate, endDate) { if (!snapshot) return this._removeEventFromAllDatesById(eventId) @@ -393,7 +438,7 @@ export const useCalendarStore = defineStore('calendar', { _findEventInAnyList(eventId) { for (const [, eventList] of this.events) { - const found = eventList.find(e => e.id === eventId) + const found = eventList.find((e) => e.id === eventId) if (found) return found } return null @@ -403,7 +448,7 @@ export const useCalendarStore = defineStore('calendar', { const startDate = fromLocalString(event.startDate) const endDate = fromLocalString(event.endDate) const cur = new Date(startDate) - + while (cur <= endDate) { const dateStr = toLocalString(cur) if (!this.events.has(dateStr)) { @@ -414,62 +459,6 @@ export const useCalendarStore = defineStore('calendar', { } }, - 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 - if (typeof id === 'string' && id.includes('_repeat_')) { - const parts = id.split('_repeat_') - const baseId = parts[0] - 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 - } - } - } - - return null - } - } + // NOTE: legacy dynamic getEventById for synthetic occurrences removed. + }, })