Corrections on store and repeats.

This commit is contained in:
Leo Vasanko 2025-08-22 12:06:04 -06:00
parent 9ab5ec8602
commit 07b22fa885
3 changed files with 322 additions and 373 deletions

View File

@ -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`
})
</script>
@ -476,15 +428,7 @@ const recurrenceSummary = computed(() => {
<span>Repeat</span>
</label>
<span class="recurrence-summary" v-if="recurrenceEnabled">
{{
recurrenceInterval === 1
? recurrenceFrequency === 'months'
? 'Monthly'
: recurrenceFrequency === 'years'
? 'Annually'
: 'Every week'
: `Every ${recurrenceInterval} ${recurrenceFrequency}`
}}
{{ recurrenceSummary }}
<template v-if="recurrenceOccurrences > 0">
until {{ formattedFinalOccurrence }}</template
>
@ -504,7 +448,6 @@ const recurrenceSummary = computed(() => {
<select v-model="recurrenceFrequency" class="freq-select">
<option value="weeks">weeks</option>
<option value="months">months</option>
<option value="years">years</option>
</select>
<Numeric
class="occ-stepper"
@ -797,6 +740,7 @@ 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);

View File

@ -3,22 +3,22 @@
<div
v-for="span in eventSpans"
:key="span.id"
class="event-span"
:class="[`event-color-${span.colorId}`]"
class="event-span"
:class="[`event-color-${span.colorId}`]"
:style="{
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
gridRow: `${span.row}`
gridRow: `${span.row}`,
}"
@click="handleEventClick(span)"
@pointerdown="handleEventPointerDown(span, $event)"
@click="handleEventClick(span)"
@pointerdown="handleEventPointerDown(span, $event)"
>
<span class="event-title">{{ span.title }}</span>
<div
class="resize-handle left"
<span class="event-title">{{ span.title }}</span>
<div
class="resize-handle left"
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
></div>
<div
class="resize-handle right"
<div
class="resize-handle right"
@pointerdown="handleResizePointerDown(span, 'resize-right', $event)"
></div>
</div>
@ -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
})

View File

@ -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.
},
})