Major new version #2
@ -142,9 +142,59 @@ function createWeek(virtualWeek) {
|
||||
let monthToLabel = null
|
||||
let labelYear = null
|
||||
|
||||
// Precollect unique repeating base events once (avoid nested loops for each day)
|
||||
const repeatingBases = []
|
||||
const seen = new Set()
|
||||
for (const [, list] of calendarStore.events) {
|
||||
for (const ev of list) {
|
||||
if (ev.isRepeating && !seen.has(ev.id)) {
|
||||
seen.add(ev.id)
|
||||
repeatingBases.push(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dateStr = toLocalString(cur)
|
||||
const eventsForDay = calendarStore.events.get(dateStr) || []
|
||||
const storedEvents = calendarStore.events.get(dateStr) || []
|
||||
// Build day events starting with stored (base/spanning) then virtual occurrences
|
||||
const dayEvents = [...storedEvents]
|
||||
for (const base of repeatingBases) {
|
||||
// Skip if the base itself already on this date (already in storedEvents)
|
||||
if (dateStr >= base.startDate && dateStr <= base.endDate) continue
|
||||
if (calendarStore.occursOnDate(base, dateStr)) {
|
||||
// Determine occurrence index (0 = first repeat after base) for weekly / monthly
|
||||
let recurrenceIndex = 0
|
||||
try {
|
||||
if (base.repeat === 'weeks') {
|
||||
const pattern = base.repeatWeekdays || []
|
||||
const baseDate = new Date(base.startDate + 'T00:00:00')
|
||||
const target = new Date(dateStr + 'T00:00:00')
|
||||
let matched = -1
|
||||
const cur = new Date(baseDate)
|
||||
while (cur < target && matched < 100000) {
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
if (pattern[cur.getDay()]) matched++
|
||||
}
|
||||
if (cur.toDateString() === target.toDateString()) recurrenceIndex = matched
|
||||
} else if (base.repeat === 'months') {
|
||||
const baseDate = new Date(base.startDate + 'T00:00:00')
|
||||
const target = new Date(dateStr + 'T00:00:00')
|
||||
const diffMonths =
|
||||
(target.getFullYear() - baseDate.getFullYear()) * 12 +
|
||||
(target.getMonth() - baseDate.getMonth())
|
||||
recurrenceIndex = diffMonths // matches existing monthly logic semantics
|
||||
}
|
||||
} catch {}
|
||||
dayEvents.push({
|
||||
...base,
|
||||
id: base.id + '_v_' + dateStr,
|
||||
startDate: dateStr,
|
||||
endDate: dateStr,
|
||||
_recurrenceIndex: recurrenceIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
const dow = cur.getDay()
|
||||
const isFirst = cur.getDate() === 1
|
||||
|
||||
@ -177,7 +227,7 @@ function createWeek(virtualWeek) {
|
||||
selection.value.dayCount > 0 &&
|
||||
dateStr >= selection.value.startDate &&
|
||||
dateStr <= addDaysStr(selection.value.startDate, selection.value.dayCount - 1),
|
||||
events: eventsForDay,
|
||||
events: dayEvents,
|
||||
})
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
|
@ -155,6 +155,8 @@ function openEditDialog(eventInstanceId) {
|
||||
let occurrenceIndex = 0
|
||||
let weekday = null
|
||||
let occurrenceDate = null
|
||||
|
||||
// Support legacy synthetic id pattern: baseId_repeat_<index>[_<weekday>]
|
||||
if (typeof eventInstanceId === 'string' && eventInstanceId.includes('_repeat_')) {
|
||||
const [bid, suffix] = eventInstanceId.split('_repeat_')
|
||||
baseId = bid
|
||||
@ -162,10 +164,57 @@ function openEditDialog(eventInstanceId) {
|
||||
occurrenceIndex = parseInt(parts[0], 10) || 0
|
||||
if (parts.length > 1) weekday = parseInt(parts[1], 10)
|
||||
}
|
||||
// Support new virtual id pattern: baseId_v_YYYY-MM-DD
|
||||
else if (typeof eventInstanceId === 'string' && eventInstanceId.includes('_v_')) {
|
||||
const splitIndex = eventInstanceId.lastIndexOf('_v_')
|
||||
if (splitIndex !== -1) {
|
||||
baseId = eventInstanceId.slice(0, splitIndex)
|
||||
const dateStr = eventInstanceId.slice(splitIndex + 3)
|
||||
occurrenceDate = new Date(dateStr + 'T00:00:00')
|
||||
// Derive occurrenceIndex based on event's repeat pattern
|
||||
const eventForIndex = calendarStore.getEventById(baseId)
|
||||
if (eventForIndex?.isRepeating) {
|
||||
if (eventForIndex.repeat === 'weeks') {
|
||||
const pattern = eventForIndex.repeatWeekdays || []
|
||||
const baseDate = new Date(eventForIndex.startDate + 'T00:00:00')
|
||||
// Count matching weekdays after base until reaching occurrenceDate
|
||||
let cur = new Date(baseDate)
|
||||
let matched = -1 // first match after base increments this to 0
|
||||
while (cur < occurrenceDate && matched < 100000) {
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
if (pattern[cur.getDay()]) matched++
|
||||
}
|
||||
if (cur.toDateString() === occurrenceDate.toDateString()) {
|
||||
occurrenceIndex = matched
|
||||
weekday = occurrenceDate.getDay()
|
||||
} else {
|
||||
// Fallback: treat as base click if something went wrong
|
||||
occurrenceIndex = 0
|
||||
weekday = null
|
||||
occurrenceDate = null
|
||||
}
|
||||
} else if (eventForIndex.repeat === 'months') {
|
||||
const baseDate = new Date(eventForIndex.startDate + 'T00:00:00')
|
||||
const diffMonths =
|
||||
(occurrenceDate.getFullYear() - baseDate.getFullYear()) * 12 +
|
||||
(occurrenceDate.getMonth() - baseDate.getMonth())
|
||||
const interval = eventForIndex.repeatInterval || 1
|
||||
// occurrenceIndex for monthly logic: diff in months (first after base is 1 * interval)
|
||||
if (diffMonths > 0 && diffMonths % interval === 0) {
|
||||
occurrenceIndex = diffMonths // matches store deletion expectation
|
||||
} else {
|
||||
occurrenceDate = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const event = calendarStore.getEventById(baseId)
|
||||
if (!event) return
|
||||
// Derive occurrence date for repeat occurrences (occurrenceIndex > 0 means not the base)
|
||||
// Derive occurrence date for repeat occurrences if not already determined above
|
||||
if (
|
||||
!occurrenceDate &&
|
||||
event.isRepeating &&
|
||||
((event.repeat === 'weeks' && occurrenceIndex >= 0) ||
|
||||
(event.repeat === 'months' && occurrenceIndex > 0))
|
||||
@ -173,8 +222,6 @@ function openEditDialog(eventInstanceId) {
|
||||
if (event.repeat === 'weeks' && occurrenceIndex >= 0) {
|
||||
const repeatWeekdaysLocal = event.repeatWeekdays || []
|
||||
const baseDate = new Date(event.startDate + 'T00:00:00')
|
||||
// occurrenceIndex counts prior occurrences AFTER base;
|
||||
// For occurrenceIndex = 0 we want first matching day after base.
|
||||
let cur = new Date(baseDate)
|
||||
let matched = -1
|
||||
let safety = 0
|
||||
|
@ -5,6 +5,7 @@
|
||||
:key="span.id"
|
||||
class="event-span"
|
||||
:class="[`event-color-${span.colorId}`]"
|
||||
:data-recurrence="span._recurrenceIndex != null ? span._recurrenceIndex : undefined"
|
||||
:style="{
|
||||
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
|
||||
gridRow: `${span.row}`,
|
||||
@ -24,54 +25,81 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||
import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/utils/date'
|
||||
import { daysInclusive, addDaysStr } from '@/utils/date'
|
||||
|
||||
const props = defineProps({
|
||||
week: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
week: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['event-click'])
|
||||
const store = useCalendarStore()
|
||||
|
||||
// Local drag state
|
||||
// Drag state
|
||||
const dragState = ref(null)
|
||||
const justDragged = ref(false)
|
||||
|
||||
// (legacy helpers removed)
|
||||
// Consolidate already-provided day.events into contiguous spans (no recurrence generation)
|
||||
const eventSpans = computed(() => {
|
||||
const weekEvents = new Map()
|
||||
props.week.days.forEach((day, dayIndex) => {
|
||||
day.events.forEach((ev) => {
|
||||
const key = ev.id
|
||||
if (!weekEvents.has(key)) {
|
||||
weekEvents.set(key, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
|
||||
} else {
|
||||
const ref = weekEvents.get(key)
|
||||
ref.endIdx = Math.max(ref.endIdx, dayIndex)
|
||||
}
|
||||
})
|
||||
})
|
||||
const arr = Array.from(weekEvents.values())
|
||||
arr.sort((a, b) => {
|
||||
const spanA = a.endIdx - a.startIdx
|
||||
const spanB = b.endIdx - b.startIdx
|
||||
if (spanA !== spanB) return spanB - spanA
|
||||
if (a.startIdx !== b.startIdx) return a.startIdx - b.startIdx
|
||||
return String(a.id).localeCompare(String(b.id))
|
||||
})
|
||||
// Assign non-overlapping rows
|
||||
const rowsLastEnd = []
|
||||
arr.forEach((ev) => {
|
||||
let row = 0
|
||||
while (row < rowsLastEnd.length && !(ev.startIdx > rowsLastEnd[row])) row++
|
||||
if (row === rowsLastEnd.length) rowsLastEnd.push(-1)
|
||||
rowsLastEnd[row] = ev.endIdx
|
||||
ev.row = row + 1
|
||||
})
|
||||
return arr
|
||||
})
|
||||
|
||||
function extractBaseId(eventId) {
|
||||
if (typeof eventId !== 'string') return eventId
|
||||
if (eventId.includes('_repeat_')) return eventId.split('_repeat_')[0]
|
||||
if (eventId.includes('_v_')) return eventId.slice(0, eventId.lastIndexOf('_v_'))
|
||||
return eventId
|
||||
}
|
||||
|
||||
// Handle event click
|
||||
function handleEventClick(span) {
|
||||
if (justDragged.value) return
|
||||
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context
|
||||
emit('event-click', span.id)
|
||||
}
|
||||
|
||||
// Handle event pointer down for dragging
|
||||
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
|
||||
|
||||
const baseId = extractBaseId(span.id)
|
||||
const isVirtual = baseId !== span.id
|
||||
startLocalDrag(
|
||||
{
|
||||
id: span.id,
|
||||
id: baseId,
|
||||
originalId: span.id,
|
||||
isVirtual,
|
||||
mode: 'move',
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
anchorDate,
|
||||
anchorDate: span.startDate,
|
||||
startDate: span.startDate,
|
||||
endDate: span.endDate,
|
||||
},
|
||||
@ -79,13 +107,15 @@ function handleEventPointerDown(span, event) {
|
||||
)
|
||||
}
|
||||
|
||||
// Handle resize handle pointer down
|
||||
function handleResizePointerDown(span, mode, event) {
|
||||
event.stopPropagation()
|
||||
// Start drag from the current edge; anchorDate not needed for resize
|
||||
const baseId = extractBaseId(span.id)
|
||||
const isVirtual = baseId !== span.id
|
||||
startLocalDrag(
|
||||
{
|
||||
id: span.id,
|
||||
id: baseId,
|
||||
originalId: span.id,
|
||||
isVirtual,
|
||||
mode,
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
@ -97,94 +127,6 @@ function handleResizePointerDown(span, mode, 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')) {
|
||||
element.style.pointerEvents = 'none'
|
||||
hiddenElements.push(element)
|
||||
element = document.elementFromPoint(clientX, clientY)
|
||||
}
|
||||
|
||||
// Restore pointer events for hidden elements
|
||||
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) {
|
||||
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)))
|
||||
|
||||
const daysGrid = weekElement.querySelector('.days-grid')
|
||||
if (daysGrid && daysGrid.children[dayIndex]) {
|
||||
const dayEl = daysGrid.children[dayIndex]
|
||||
const date = dayEl?.dataset?.date
|
||||
if (date) return { date }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find the week overlay and calculate position
|
||||
const overlayEl = targetEl?.closest('.week-overlay')
|
||||
const weekElement = overlayEl ? overlayEl.parentElement : null
|
||||
if (!weekElement) {
|
||||
// If we're outside this week, try to find any week element under the pointer
|
||||
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) {
|
||||
const distance = Math.abs(clientY - (rect.top + rect.height / 2))
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance
|
||||
bestWeek = week
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
const date = dayEl?.dataset?.date
|
||||
if (date) return { date }
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Local drag handling
|
||||
function startLocalDrag(init, evt) {
|
||||
const spanDays = daysInclusive(init.startDate, init.endDate)
|
||||
@ -292,250 +234,12 @@ function normalizeDateOrder(aStr, bStr) {
|
||||
}
|
||||
|
||||
function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
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
|
||||
|
||||
const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move'
|
||||
if (isRepeatOccurrence) {
|
||||
if (repeatIndex === 0) {
|
||||
store.setEventRange(baseId, startDate, endDate, { mode })
|
||||
} else {
|
||||
if (!st.splitNewBaseId) {
|
||||
const newId = store.splitRepeatSeries(
|
||||
baseId,
|
||||
repeatIndex,
|
||||
startDate,
|
||||
endDate,
|
||||
grabbedWeekday,
|
||||
)
|
||||
if (newId) {
|
||||
st.splitNewBaseId = newId
|
||||
st.id = newId
|
||||
st.startDate = startDate
|
||||
st.endDate = endDate
|
||||
}
|
||||
} else {
|
||||
store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.setEventRange(st.id, startDate, endDate, { mode })
|
||||
}
|
||||
// If dragging a virtual occurrence, map to base move without changing recurrence pattern mid-series.
|
||||
// We disallow resizing individual virtual occurrences; treat as move of whole series anchor.
|
||||
if (st.isVirtual && st.mode !== 'move') return
|
||||
store.setEventRange(st.id, startDate, endDate, { mode: st.mode })
|
||||
}
|
||||
|
||||
// Calculate event spans for this week
|
||||
const eventSpans = computed(() => {
|
||||
const spans = []
|
||||
const weekEvents = new Map()
|
||||
|
||||
// Collect stored base events
|
||||
props.week.days.forEach((day, dayIndex) => {
|
||||
day.events.forEach((ev) => {
|
||||
if (!weekEvents.has(ev.id)) {
|
||||
weekEvents.set(ev.id, { ...ev, startIdx: dayIndex, endIdx: dayIndex })
|
||||
} else weekEvents.get(ev.id).endIdx = dayIndex
|
||||
})
|
||||
})
|
||||
|
||||
// Generate virtual repeats numerically
|
||||
const weekStart = fromLocalString(props.week.days[0].date)
|
||||
const weekEnd = fromLocalString(props.week.days[6].date)
|
||||
const weekStartTime = weekStart.getTime()
|
||||
const weekEndTime = weekEnd.getTime()
|
||||
const DAY_MS = 86400000
|
||||
|
||||
// All repeating base events
|
||||
const baseEvents = []
|
||||
const seen = new Set()
|
||||
for (const [, list] of store.events) {
|
||||
for (const ev of list) {
|
||||
if (ev.isRepeating && !seen.has(ev.id)) {
|
||||
seen.add(ev.id)
|
||||
baseEvents.push(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const base of baseEvents) {
|
||||
if (!base.isRepeating || base.repeat === 'none') continue
|
||||
const baseStart = fromLocalString(base.startDate)
|
||||
const baseEnd = fromLocalString(base.endDate)
|
||||
const spanDays = Math.floor((baseEnd - baseStart) / DAY_MS)
|
||||
const maxOccurrences =
|
||||
base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10)
|
||||
if (maxOccurrences === 0) continue
|
||||
|
||||
if (base.repeat === 'weeks') {
|
||||
const pattern = base.repeatWeekdays || []
|
||||
const interval = base.repeatInterval || 1
|
||||
if (!pattern.some(Boolean)) continue
|
||||
// Align base block start to week (Sunday=0)
|
||||
const baseBlockStart = new Date(baseStart)
|
||||
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
|
||||
// Search window
|
||||
const searchStart = new Date(weekStart)
|
||||
searchStart.setDate(searchStart.getDate() - 7) // one block back for early-week carries
|
||||
const searchEnd = new Date(weekEnd)
|
||||
searchEnd.setDate(searchEnd.getDate() + 7) // one block forward for late-week upcoming
|
||||
const startBlocks = Math.floor((searchStart - baseBlockStart) / (7 * DAY_MS))
|
||||
const endBlocks = Math.floor((searchEnd - baseBlockStart) / (7 * DAY_MS))
|
||||
for (let b = Math.max(0, startBlocks); b <= endBlocks; b++) {
|
||||
if (b % interval !== 0) continue
|
||||
const blockStart = new Date(baseBlockStart)
|
||||
blockStart.setDate(baseBlockStart.getDate() + b * 7)
|
||||
for (let dow = 0; dow < 7; dow++) {
|
||||
if (!pattern[dow]) continue
|
||||
const cand = new Date(blockStart)
|
||||
cand.setDate(blockStart.getDate() + dow)
|
||||
if (cand < baseStart) continue
|
||||
const isBase = cand.getTime() === baseStart.getTime()
|
||||
const candStartTime = cand.getTime()
|
||||
const candEndTime = candStartTime + spanDays * DAY_MS
|
||||
const overlaps = candStartTime <= weekEndTime && candEndTime >= weekStartTime
|
||||
if (!isBase && overlaps) {
|
||||
let occIdx = 0
|
||||
const cursor = new Date(baseStart)
|
||||
while (cursor < cand && occIdx < maxOccurrences) {
|
||||
const weeksFromBase = Math.floor((cursor - baseBlockStart) / (7 * DAY_MS))
|
||||
if (
|
||||
weeksFromBase % interval === 0 &&
|
||||
pattern[cursor.getDay()] &&
|
||||
cursor.getTime() !== baseStart.getTime()
|
||||
) {
|
||||
occIdx++
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
if (occIdx >= maxOccurrences && isFinite(maxOccurrences)) break
|
||||
const occStartStr = toLocalString(cand)
|
||||
const occEnd = new Date(cand)
|
||||
occEnd.setDate(occEnd.getDate() + spanDays)
|
||||
const occEndStr = toLocalString(occEnd)
|
||||
let startIdx = -1
|
||||
let endIdx = -1
|
||||
props.week.days.forEach((d, idx) => {
|
||||
if (startIdx === -1 && d.date >= occStartStr && d.date <= occEndStr) startIdx = idx
|
||||
if (d.date >= occStartStr && d.date <= occEndStr) endIdx = idx
|
||||
})
|
||||
const id = `${base.id}_repeat_${occIdx}_${cand.getDay()}`
|
||||
if ((startIdx !== -1 || endIdx !== -1) && !weekEvents.has(id)) {
|
||||
weekEvents.set(id, {
|
||||
...base,
|
||||
id,
|
||||
startDate: occStartStr,
|
||||
endDate: occEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: occIdx,
|
||||
startIdx: startIdx === -1 ? 0 : startIdx,
|
||||
endIdx: endIdx === -1 ? 6 : endIdx,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (base.repeat === 'months') {
|
||||
const interval = base.repeatInterval || 1
|
||||
const baseDay = baseStart.getDate()
|
||||
const startMonthIndex = baseStart.getFullYear() * 12 + baseStart.getMonth()
|
||||
const endMonthIndex = weekEnd.getFullYear() * 12 + weekEnd.getMonth()
|
||||
for (let mi = startMonthIndex; mi <= endMonthIndex + 1; mi++) {
|
||||
// +1 to catch overlap spilling in
|
||||
const diff = mi - startMonthIndex
|
||||
if (diff === 0) continue // base occurrence already stored
|
||||
if (diff % interval !== 0) continue
|
||||
if (diff > maxOccurrences && isFinite(maxOccurrences)) break
|
||||
const y = Math.floor(mi / 12)
|
||||
const m = mi % 12
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate()
|
||||
const dom = Math.min(baseDay, daysInMonth)
|
||||
const cand = new Date(y, m, dom)
|
||||
if (cand < baseStart) continue
|
||||
const candEnd = new Date(cand)
|
||||
const spanDays = Math.floor((baseEnd - baseStart) / DAY_MS)
|
||||
candEnd.setDate(candEnd.getDate() + spanDays)
|
||||
const candStartStr = toLocalString(cand)
|
||||
const candEndStr = toLocalString(candEnd)
|
||||
const overlaps = cand.getTime() <= weekEndTime && candEnd.getTime() >= weekStartTime
|
||||
if (!overlaps) continue
|
||||
let startIdx = -1
|
||||
let endIdx = -1
|
||||
props.week.days.forEach((d, idx) => {
|
||||
if (startIdx === -1 && d.date >= candStartStr && d.date <= candEndStr) startIdx = idx
|
||||
if (d.date >= candStartStr && d.date <= candEndStr) endIdx = idx
|
||||
})
|
||||
if (startIdx === -1 && endIdx === -1) continue
|
||||
const id = `${base.id}_repeat_${diff}`
|
||||
if (!weekEvents.has(id)) {
|
||||
weekEvents.set(id, {
|
||||
...base,
|
||||
id,
|
||||
startDate: candStartStr,
|
||||
endDate: candEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: diff,
|
||||
startIdx: startIdx === -1 ? 0 : startIdx,
|
||||
endIdx: endIdx === -1 ? 6 : endIdx,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort
|
||||
const eventArray = Array.from(weekEvents.values())
|
||||
eventArray.sort((a, b) => {
|
||||
// Sort by span length (longer first)
|
||||
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) => {
|
||||
let placedRow = 0
|
||||
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
|
||||
placedRow++
|
||||
}
|
||||
if (placedRow === rowsLastEnd.length) {
|
||||
rowsLastEnd.push(-1)
|
||||
}
|
||||
rowsLastEnd[placedRow] = event.endIdx
|
||||
event.row = placedRow + 1
|
||||
})
|
||||
|
||||
return eventArray
|
||||
})
|
||||
|
||||
function timeToMinutes(timeStr) {
|
||||
if (!timeStr) return 0
|
||||
const [hours, minutes] = timeStr.split(':').map(Number)
|
||||
|
@ -25,6 +25,77 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
},
|
||||
|
||||
actions: {
|
||||
// Determine if a repeating event occurs on a specific date (YYYY-MM-DD) without iterating all occurrences.
|
||||
occursOnDate(event, dateStr) {
|
||||
if (!event || !event.isRepeating || event.repeat === 'none') return false
|
||||
// Quick bounds: event cannot occur before its base start
|
||||
if (dateStr < event.startDate) return false
|
||||
// For multi-day spanning events, we treat start date as anchor; UI handles span painting separately.
|
||||
if (event.repeat === 'weeks') {
|
||||
const pattern = event.repeatWeekdays || []
|
||||
if (!pattern.some(Boolean)) return false
|
||||
// Day of week must match
|
||||
const d = fromLocalString(dateStr)
|
||||
const dow = d.getDay()
|
||||
if (!pattern[dow]) return false
|
||||
// Compute week distance blocks respecting interval by counting ISO weeks since anchor Monday of base.
|
||||
const baseStart = fromLocalString(event.startDate)
|
||||
// If date is before base anchor weekday match, ensure anchor alignment
|
||||
// Count days since base start; ensure that number of matching weekdays encountered equals occurrence index < repeatCount
|
||||
// Optimized approach: approximate max occurrences cap first.
|
||||
const interval = event.repeatInterval || 1
|
||||
// Check if date resides in a week block that aligns with interval
|
||||
const baseBlockStart = new Date(baseStart)
|
||||
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
|
||||
const currentBlockStart = new Date(d)
|
||||
currentBlockStart.setDate(d.getDate() - d.getDay())
|
||||
const WEEK_MS = 7 * 86400000
|
||||
const blocksDiff = Math.floor((currentBlockStart - baseBlockStart) / WEEK_MS)
|
||||
if (blocksDiff < 0 || blocksDiff % interval !== 0) return false
|
||||
// Count occurrences up to this date to enforce repeatCount finite limits
|
||||
if (event.repeatCount !== 'unlimited') {
|
||||
const targetTime = d.getTime()
|
||||
let occs = 0
|
||||
const cursor = new Date(baseStart)
|
||||
const limit = parseInt(event.repeatCount, 10)
|
||||
const safetyLimit = Math.min(limit + 1, 10000)
|
||||
while (cursor.getTime() <= targetTime && occs < safetyLimit) {
|
||||
if (pattern[cursor.getDay()]) {
|
||||
if (cursor.getTime() === targetTime) {
|
||||
// This is the occurrence. Validate occs < limit
|
||||
return occs < limit
|
||||
}
|
||||
occs++
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else if (event.repeat === 'months') {
|
||||
const baseStart = fromLocalString(event.startDate)
|
||||
const d = fromLocalString(dateStr)
|
||||
const diffMonths =
|
||||
(d.getFullYear() - baseStart.getFullYear()) * 12 + (d.getMonth() - baseStart.getMonth())
|
||||
if (diffMonths < 0) return false
|
||||
const interval = event.repeatInterval || 1
|
||||
if (diffMonths % interval !== 0) return false
|
||||
// Check day match (clamped for shorter months). Base day might exceed target month length.
|
||||
const baseDay = baseStart.getDate()
|
||||
const daysInMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
|
||||
const effectiveDay = Math.min(baseDay, daysInMonth)
|
||||
if (d.getDate() !== effectiveDay) return false
|
||||
if (event.repeatCount !== 'unlimited') {
|
||||
const limit = parseInt(event.repeatCount, 10)
|
||||
if (isNaN(limit)) return false
|
||||
// Base is occurrence 0; diffMonths/interval gives occurrence index
|
||||
const occurrenceIndex = diffMonths / interval
|
||||
return occurrenceIndex < limit
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
updateCurrentDate() {
|
||||
const d = new Date()
|
||||
this.now = d.toISOString()
|
||||
@ -133,28 +204,96 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
if (!base || !base.isRepeating) return
|
||||
// WEEKLY SERIES ------------------------------------------------------
|
||||
if (base.repeat === 'weeks') {
|
||||
// Strategy: split series around the target occurrence, omitting it.
|
||||
const remaining =
|
||||
base.repeatCount === 'unlimited'
|
||||
? 'unlimited'
|
||||
: String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1)))
|
||||
// Keep occurrences before the deleted one
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
if (remaining === '0') return
|
||||
// Find date of next occurrence (first after deleted)
|
||||
const startDate = new Date(base.startDate + 'T00:00:00')
|
||||
let idx = 0
|
||||
let cur = new Date(startDate)
|
||||
while (idx <= occurrenceIndex && idx < 10000) {
|
||||
const interval = base.repeatInterval || 1
|
||||
const pattern = base.repeatWeekdays || []
|
||||
if (!pattern.some(Boolean)) return
|
||||
// Preserve original count before any truncation
|
||||
const originalCountRaw = base.repeatCount
|
||||
|
||||
// Determine target occurrence date
|
||||
let targetDate = null
|
||||
if (ctx.occurrenceDate instanceof Date) {
|
||||
targetDate = new Date(
|
||||
ctx.occurrenceDate.getFullYear(),
|
||||
ctx.occurrenceDate.getMonth(),
|
||||
ctx.occurrenceDate.getDate(),
|
||||
)
|
||||
} else {
|
||||
// Fallback: derive from occurrenceIndex (legacy path)
|
||||
const baseStart = new Date(base.startDate + 'T00:00:00')
|
||||
let cur = new Date(baseStart)
|
||||
let matched = -1
|
||||
let safety = 0
|
||||
while (matched < occurrenceIndex && safety < 20000) {
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
if (base.repeatWeekdays && base.repeatWeekdays[cur.getDay()]) idx++
|
||||
const blockStart = new Date(cur)
|
||||
blockStart.setDate(cur.getDate() - cur.getDay())
|
||||
const baseBlockStart = new Date(baseStart)
|
||||
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
|
||||
const WEEK_MS = 7 * 86400000
|
||||
const blocksDiff = Math.floor((blockStart - baseBlockStart) / WEEK_MS)
|
||||
const aligned = blocksDiff % interval === 0
|
||||
if (aligned && pattern[cur.getDay()]) matched++
|
||||
safety++
|
||||
}
|
||||
const nextStartStr = toLocalString(cur)
|
||||
// Preserve multi‑day span if any
|
||||
targetDate = cur
|
||||
}
|
||||
if (!targetDate) return
|
||||
|
||||
// Count occurrences BEFORE target (always include the base occurrence as first)
|
||||
const baseStart = new Date(base.startDate + 'T00:00:00')
|
||||
const baseBlockStart = new Date(baseStart)
|
||||
baseBlockStart.setDate(baseStart.getDate() - baseStart.getDay())
|
||||
const WEEK_MS = 7 * 86400000
|
||||
function isAligned(d) {
|
||||
const block = new Date(d)
|
||||
block.setDate(d.getDate() - d.getDay())
|
||||
const diff = Math.floor((block - baseBlockStart) / WEEK_MS)
|
||||
return diff % interval === 0
|
||||
}
|
||||
// Start with 1 (base occurrence) if target is after base; if target IS base (should not happen here) leave 0
|
||||
let countBefore = targetDate > baseStart ? 1 : 0
|
||||
let probe = new Date(baseStart)
|
||||
probe.setDate(probe.getDate() + 1) // start counting AFTER base
|
||||
let safety2 = 0
|
||||
while (probe < targetDate && safety2 < 50000) {
|
||||
if (pattern[probe.getDay()] && isAligned(probe)) countBefore++
|
||||
probe.setDate(probe.getDate() + 1)
|
||||
safety2++
|
||||
}
|
||||
// Terminate original series to keep only occurrences before target
|
||||
this._terminateRepeatSeriesAtIndex(baseId, countBefore)
|
||||
|
||||
// Calculate remaining occurrences for new series using ORIGINAL total
|
||||
let remainingCount = 'unlimited'
|
||||
if (originalCountRaw !== 'unlimited') {
|
||||
const originalTotal = parseInt(originalCountRaw, 10)
|
||||
if (!isNaN(originalTotal)) {
|
||||
const rem = originalTotal - countBefore - 1 // kept + deleted
|
||||
if (rem <= 0) return // nothing left to continue
|
||||
remainingCount = String(rem)
|
||||
}
|
||||
}
|
||||
|
||||
// Continuation starts at NEXT valid occurrence (matching weekday & aligned block)
|
||||
let continuationStart = new Date(targetDate)
|
||||
let searchSafety = 0
|
||||
let foundNext = false
|
||||
while (searchSafety < 50000) {
|
||||
continuationStart.setDate(continuationStart.getDate() + 1)
|
||||
if (pattern[continuationStart.getDay()] && isAligned(continuationStart)) {
|
||||
foundNext = true
|
||||
break
|
||||
}
|
||||
searchSafety++
|
||||
}
|
||||
if (!foundNext) return // no remaining occurrences
|
||||
|
||||
const spanDays = Math.round(
|
||||
(fromLocalString(base.endDate) - fromLocalString(base.startDate)) / (24 * 60 * 60 * 1000),
|
||||
)
|
||||
const nextEnd = new Date(fromLocalString(nextStartStr))
|
||||
const nextStartStr = toLocalString(continuationStart)
|
||||
const nextEnd = new Date(continuationStart)
|
||||
nextEnd.setDate(nextEnd.getDate() + spanDays)
|
||||
const nextEndStr = toLocalString(nextEnd)
|
||||
this.createEvent({
|
||||
@ -163,7 +302,8 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
endDate: nextEndStr,
|
||||
colorId: base.colorId,
|
||||
repeat: 'weeks',
|
||||
repeatCount: remaining,
|
||||
repeatInterval: interval,
|
||||
repeatCount: remainingCount,
|
||||
repeatWeekdays: base.repeatWeekdays,
|
||||
})
|
||||
return
|
||||
@ -175,6 +315,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
if (occurrenceIndex <= 0) return // base itself handled elsewhere
|
||||
if (occurrenceIndex % interval !== 0) return // should not happen (synthetic id only for valid occurrences)
|
||||
// Count prior occurrences (including base) before the deleted one
|
||||
const originalCountRaw = base.repeatCount
|
||||
const priorOccurrences = Math.floor((occurrenceIndex - 1) / interval) + 1
|
||||
// Truncate base series to keep only priorOccurrences
|
||||
this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences)
|
||||
@ -184,8 +325,8 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
)
|
||||
// Remaining occurrences after deletion
|
||||
let remainingCount = 'unlimited'
|
||||
if (base.repeatCount !== 'unlimited') {
|
||||
const total = parseInt(base.repeatCount, 10)
|
||||
if (originalCountRaw !== 'unlimited') {
|
||||
const total = parseInt(originalCountRaw, 10)
|
||||
if (!isNaN(total)) {
|
||||
const rem = total - priorOccurrences - 1 // subtract kept + deleted
|
||||
if (rem <= 0) return // nothing left
|
||||
@ -214,7 +355,13 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
|
||||
deleteFromOccurrence(ctx) {
|
||||
const { baseId, occurrenceIndex } = ctx
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
const base = this.getEventById(baseId)
|
||||
if (!base || !base.isRepeating) return
|
||||
// We want to keep occurrences up to and including the selected one; that becomes new repeatCount.
|
||||
// occurrenceIndex here represents the number of repeats AFTER the base (weekly: 0 = first repeat; monthly: diffMonths)
|
||||
// Total kept occurrences = base (1) + occurrenceIndex
|
||||
const keptTotal = 1 + Math.max(0, occurrenceIndex)
|
||||
this._terminateRepeatSeriesAtIndex(baseId, keptTotal)
|
||||
},
|
||||
|
||||
deleteFirstOccurrence(baseId) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user