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 title = ref('')
const recurrenceEnabled = ref(false) const recurrenceEnabled = ref(false)
const recurrenceInterval = ref(1) // N in "Every N weeks/months" 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 recurrenceWeekdays = ref([false, false, false, false, false, false, false])
const recurrenceOccurrences = ref(0) // 0 = unlimited const recurrenceOccurrences = ref(0) // 0 = unlimited
const colorId = ref(0) const colorId = ref(0)
@ -42,31 +42,11 @@ const fallbackWeekdays = computed(() => {
return fallback return fallback
}) })
function preventFocusOnMouseDown(event) { // Repeat mapping uses 'weeks' | 'months' | 'none' directly (legacy 'weekly'/'monthly' accepted on load)
// Prevent focus when clicking with mouse, but allow keyboard navigation
event.preventDefault()
}
// Bridge legacy repeat API (store still expects repeat & repeatWeekdays)
const repeat = computed({ const repeat = computed({
get() { get() {
if (!recurrenceEnabled.value) return 'none' if (!recurrenceEnabled.value) return 'none'
if (recurrenceFrequency.value === 'weeks') { return recurrenceFrequency.value // 'weeks' | 'months'
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'
}
}, },
set(val) { set(val) {
if (val === 'none') { if (val === 'none') {
@ -74,27 +54,8 @@ const repeat = computed({
return return
} }
recurrenceEnabled.value = true recurrenceEnabled.value = true
switch (val) { if (val === 'weeks' || val === 'weekly') recurrenceFrequency.value = 'weeks'
case 'weekly': else if (val === 'months' || val === 'monthly') recurrenceFrequency.value = 'months'
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
}
}, },
}) })
@ -153,6 +114,7 @@ function openCreateDialog() {
endDate: props.selection.end, endDate: props.selection.end,
colorId: colorId.value, colorId: colorId.value,
repeat: repeat.value, repeat: repeat.value,
repeatInterval: recurrenceInterval.value,
repeatCount: repeatCount:
recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value), recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value),
repeatWeekdays: buildStoreWeekdayPattern(), repeatWeekdays: buildStoreWeekdayPattern(),
@ -204,6 +166,7 @@ function openEditDialog(eventInstanceId) {
title.value = event.title title.value = event.title
loadWeekdayPatternFromStore(event.repeatWeekdays) loadWeekdayPatternFromStore(event.repeatWeekdays)
repeat.value = event.repeat // triggers setter mapping into recurrence state repeat.value = event.repeat // triggers setter mapping into recurrence state
if (event.repeatInterval) recurrenceInterval.value = event.repeatInterval
// Map repeatCount // Map repeatCount
const rc = event.repeatCount ?? 'unlimited' const rc = event.repeatCount ?? 'unlimited'
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0 recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
@ -244,6 +207,7 @@ function updateEventInStore() {
event.title = title.value event.title = title.value
event.colorId = colorId.value event.colorId = colorId.value
event.repeat = repeat.value event.repeat = repeat.value
event.repeatInterval = recurrenceInterval.value
event.repeatWeekdays = buildStoreWeekdayPattern() event.repeatWeekdays = buildStoreWeekdayPattern()
event.repeatCount = event.repeatCount =
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value) recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
@ -304,7 +268,7 @@ watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
watch( watch(
recurrenceWeekdays, recurrenceWeekdays,
() => { () => {
if (editingEventId.value && showDialog.value && repeat.value === 'weekly') updateEventInStore() if (editingEventId.value && showDialog.value && repeat.value === 'weeks') updateEventInStore()
}, },
{ deep: true }, { deep: true },
) )
@ -396,12 +360,6 @@ const finalOccurrenceDate = computed(() => {
const d = new Date(start) const d = new Date(start)
d.setMonth(d.getMonth() + monthsToAdd) d.setMonth(d.getMonth() + monthsToAdd)
return d 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(() => { const recurrenceSummary = computed(() => {
if (!recurrenceEnabled.value) return 'Does not recur' 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') { if (recurrenceFrequency.value === 'weeks') {
const sel = weekdays.filter((_, i) => recurrenceWeekdays.value[i]) return recurrenceInterval.value === 1 ? 'Weekly' : `Every ${recurrenceInterval.value} weeks`
if (sel.length) base += ' on ' + sel.join(', ')
} }
base += // months frequency
' · ' + if (recurrenceInterval.value % 12 === 0) {
(recurrenceOccurrences.value === 0 const years = recurrenceInterval.value / 12
? 'no end' return years === 1 ? 'Annually' : `Every ${years} years`
: `${recurrenceOccurrences.value} ${recurrenceOccurrences.value === 1 ? 'time' : 'times'}`) }
return base return recurrenceInterval.value === 1 ? 'Monthly' : `Every ${recurrenceInterval.value} months`
}) })
</script> </script>
@ -476,15 +428,7 @@ const recurrenceSummary = computed(() => {
<span>Repeat</span> <span>Repeat</span>
</label> </label>
<span class="recurrence-summary" v-if="recurrenceEnabled"> <span class="recurrence-summary" v-if="recurrenceEnabled">
{{ {{ recurrenceSummary }}
recurrenceInterval === 1
? recurrenceFrequency === 'months'
? 'Monthly'
: recurrenceFrequency === 'years'
? 'Annually'
: 'Every week'
: `Every ${recurrenceInterval} ${recurrenceFrequency}`
}}
<template v-if="recurrenceOccurrences > 0"> <template v-if="recurrenceOccurrences > 0">
until {{ formattedFinalOccurrence }}</template until {{ formattedFinalOccurrence }}</template
> >
@ -504,7 +448,6 @@ const recurrenceSummary = computed(() => {
<select v-model="recurrenceFrequency" class="freq-select"> <select v-model="recurrenceFrequency" class="freq-select">
<option value="weeks">weeks</option> <option value="weeks">weeks</option>
<option value="months">months</option> <option value="months">months</option>
<option value="years">years</option>
</select> </select>
<Numeric <Numeric
class="occ-stepper" class="occ-stepper"
@ -797,6 +740,7 @@ const recurrenceSummary = computed(() => {
font-size: 0.75rem; font-size: 0.75rem;
border: 1px solid var(--input-border); border: 1px solid var(--input-border);
background: var(--panel-alt); background: var(--panel-alt);
color: var(--ink);
border-radius: 0.45rem; border-radius: 0.45rem;
transition: transition:
border-color 0.18s ease, border-color 0.18s ease,
@ -806,6 +750,7 @@ const recurrenceSummary = computed(() => {
outline: none; outline: none;
border-color: var(--input-focus); border-color: var(--input-focus);
background: var(--panel-accent); background: var(--panel-accent);
color: var(--ink);
box-shadow: box-shadow:
0 0 0 1px var(--input-focus), 0 0 0 1px var(--input-focus),
0 0 0 4px rgba(37, 99, 235, 0.15); 0 0 0 4px rgba(37, 99, 235, 0.15);

View File

@ -3,16 +3,16 @@
<div <div
v-for="span in eventSpans" v-for="span in eventSpans"
:key="span.id" :key="span.id"
class="event-span" class="event-span"
:class="[`event-color-${span.colorId}`]" :class="[`event-color-${span.colorId}`]"
:style="{ :style="{
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`, gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
gridRow: `${span.row}` gridRow: `${span.row}`,
}" }"
@click="handleEventClick(span)" @click="handleEventClick(span)"
@pointerdown="handleEventPointerDown(span, $event)" @pointerdown="handleEventPointerDown(span, $event)"
> >
<span class="event-title">{{ span.title }}</span> <span class="event-title">{{ span.title }}</span>
<div <div
class="resize-handle left" class="resize-handle left"
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)" @pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
@ -33,8 +33,8 @@ import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/uti
const props = defineProps({ const props = defineProps({
week: { week: {
type: Object, type: Object,
required: true required: true,
} },
}) })
const emit = defineEmits(['event-click']) const emit = defineEmits(['event-click'])
@ -60,31 +60,38 @@ function generateRepeatOccurrencesForDate(targetDateStr) {
const baseEndDate = new Date(fromLocalString(baseEvent.endDate)) const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000)) const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
if (baseEvent.repeat === 'weekly') { if (baseEvent.repeat === 'weeks') {
const repeatWeekdays = baseEvent.repeatWeekdays const repeatWeekdays = baseEvent.repeatWeekdays
const targetWeekday = targetDate.getDay() const targetWeekday = targetDate.getDay()
if (!repeatWeekdays[targetWeekday]) continue if (!repeatWeekdays[targetWeekday]) continue
if (targetDate < baseStartDate) 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 if (maxOccurrences === 0) continue
// Count occurrences from start up to (and including) target // Count occurrences from start up to (and including) target
let occIdx = 0 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) const cursor = new Date(baseStartDate)
while (cursor < targetDate && occIdx < maxOccurrences) { 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) cursor.setDate(cursor.getDate() + 1)
} }
// If target itself is the base start and it's selected, occIdx == 0 => base event (skip) if (targetDate.getTime() === baseStartDate.getTime()) {
if (cursor.getTime() === targetDate.getTime()) { // skip base occurrence
// We haven't advanced past target, so if its weekday is selected and this is the first occurrence, skip continue
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 (occIdx >= maxOccurrences) continue if (occIdx >= maxOccurrences) continue
const occStart = new Date(targetDate) 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 occStartStr = toLocalString(occStart)
const occEndStr = toLocalString(occEnd) const occEndStr = toLocalString(occEnd)
occurrences.push({ occurrences.push({
@ -93,66 +100,46 @@ function generateRepeatOccurrencesForDate(targetDateStr) {
startDate: occStartStr, startDate: occStartStr,
endDate: occEndStr, endDate: occEndStr,
isRepeatOccurrence: true, isRepeatOccurrence: true,
repeatIndex: occIdx repeatIndex: occIdx,
}) })
continue continue
} else { } else {
// Handle other repeat types (biweekly, monthly, yearly) // Handle other repeat types (months)
let intervalsPassed = 0 let intervalsPassed = 0
const timeDiff = targetDate - baseStartDate const timeDiff = targetDate - baseStartDate
if (baseEvent.repeat === 'months') {
switch (baseEvent.repeat) { intervalsPassed =
case 'biweekly': (targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
intervalsPassed = Math.floor(timeDiff / (14 * 24 * 60 * 60 * 1000)) (targetDate.getMonth() - baseStartDate.getMonth())
break } else {
case 'monthly': continue
intervalsPassed = Math.floor((targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
(targetDate.getMonth() - baseStartDate.getMonth()))
break
case 'yearly':
intervalsPassed = targetDate.getFullYear() - baseStartDate.getFullYear()
break
} }
const interval = baseEvent.repeatInterval || 1
if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue
// Check a few occurrences around the target date // Check a few occurrences around the target date
for (let i = Math.max(0, intervalsPassed - 2); i <= intervalsPassed + 2; i++) { const maxOccurrences =
const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10) baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
if (i >= maxOccurrences) break if (maxOccurrences === 0) continue
const i = intervalsPassed
const currentStart = new Date(baseStartDate) if (i >= maxOccurrences) continue
// Skip base occurrence
switch (baseEvent.repeat) { if (i === 0) continue
case 'biweekly': const currentStart = new Date(baseStartDate)
currentStart.setDate(baseStartDate.getDate() + i * 14) currentStart.setMonth(baseStartDate.getMonth() + i)
break const currentEnd = new Date(currentStart)
case 'monthly': currentEnd.setDate(currentStart.getDate() + spanDays)
currentStart.setMonth(baseStartDate.getMonth() + i) const currentStartStr = toLocalString(currentStart)
break const currentEndStr = toLocalString(currentEnd)
case 'yearly': if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
currentStart.setFullYear(baseStartDate.getFullYear() + i) occurrences.push({
break ...baseEvent,
} id: `${baseEvent.id}_repeat_${i}`,
startDate: currentStartStr,
const currentEnd = new Date(currentStart) endDate: currentEndStr,
currentEnd.setDate(currentStart.getDate() + spanDays) isRepeatOccurrence: true,
repeatIndex: i,
// 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
})
}
} }
} }
} }
@ -188,30 +175,36 @@ function handleEventPointerDown(span, event) {
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget) const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget)
const anchorDate = hit ? hit.date : span.startDate const anchorDate = hit ? hit.date : span.startDate
startLocalDrag({ startLocalDrag(
id: span.id, {
mode: 'move', id: span.id,
pointerStartX: event.clientX, mode: 'move',
pointerStartY: event.clientY, pointerStartX: event.clientX,
anchorDate, pointerStartY: event.clientY,
startDate: span.startDate, anchorDate,
endDate: span.endDate startDate: span.startDate,
}, event) endDate: span.endDate,
},
event,
)
} }
// Handle resize handle pointer down // Handle resize handle pointer down
function handleResizePointerDown(span, mode, event) { function handleResizePointerDown(span, mode, event) {
event.stopPropagation() event.stopPropagation()
// Start drag from the current edge; anchorDate not needed for resize // Start drag from the current edge; anchorDate not needed for resize
startLocalDrag({ startLocalDrag(
id: span.id, {
mode, id: span.id,
pointerStartX: event.clientX, mode,
pointerStartY: event.clientY, pointerStartX: event.clientX,
anchorDate: null, pointerStartY: event.clientY,
startDate: span.startDate, anchorDate: null,
endDate: span.endDate startDate: span.startDate,
}, event) endDate: span.endDate,
},
event,
)
} }
// Get date under pointer coordinates // Get date under pointer coordinates
@ -228,7 +221,7 @@ function getDateUnderPointer(clientX, clientY, targetEl) {
} }
// Restore pointer events for hidden elements // Restore pointer events for hidden elements
hiddenElements.forEach(el => el.style.pointerEvents = 'auto') hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto'))
if (element) { if (element) {
// Look for a day cell with data-date attribute // Look for a day cell with data-date attribute
@ -316,7 +309,7 @@ function startLocalDrag(init, evt) {
...init, ...init,
anchorOffset, anchorOffset,
originSpanDays: spanDays, originSpanDays: spanDays,
eventMoved: false eventMoved: false,
} }
// Capture pointer events globally // Capture pointer events globally
@ -378,7 +371,9 @@ function onDragPointerUp(e) {
if (moved) { if (moved) {
justDragged.value = true 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) { function applyRangeDuringDrag(st, startDate, endDate) {
const ev = store.getEventById(st.id) let ev = store.getEventById(st.id)
if (!ev) return let isRepeatOccurrence = false
if (ev.isRepeatOccurrence) { let baseId = st.id
const idParts = String(st.id).split('_repeat_') let repeatIndex = 0
const baseId = idParts[0] let grabbedWeekday = null
const repeatParts = idParts[1].split('_')
const repeatIndex = parseInt(repeatParts[0], 10) || 0
const grabbedWeekday = repeatParts.length > 1 ? parseInt(repeatParts[1], 10) : 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) { if (repeatIndex === 0) {
store.setEventRange(baseId, startDate, endDate) store.setEventRange(baseId, startDate, endDate, { mode })
} else { } else {
if (!st.splitNewBaseId) { if (!st.splitNewBaseId) {
const newId = store.splitRepeatSeries(baseId, repeatIndex, startDate, endDate, grabbedWeekday) const newId = store.splitRepeatSeries(
baseId,
repeatIndex,
startDate,
endDate,
grabbedWeekday,
)
if (newId) { if (newId) {
st.splitNewBaseId = newId st.splitNewBaseId = newId
st.id = newId st.id = newId
@ -428,11 +443,11 @@ function applyRangeDuringDrag(st, startDate, endDate) {
st.endDate = endDate st.endDate = endDate
} }
} else { } else {
store.setEventRange(st.splitNewBaseId, startDate, endDate) store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
} }
} }
} else { } else {
store.setEventRange(st.id, startDate, endDate) store.setEventRange(st.id, startDate, endDate, { mode })
} }
} }
@ -444,12 +459,12 @@ const eventSpans = computed(() => {
// Collect events from all days in this week, including repeat occurrences // Collect events from all days in this week, including repeat occurrences
props.week.days.forEach((day, dayIndex) => { props.week.days.forEach((day, dayIndex) => {
// Get base events for this day // Get base events for this day
day.events.forEach(event => { day.events.forEach((event) => {
if (!weekEvents.has(event.id)) { if (!weekEvents.has(event.id)) {
weekEvents.set(event.id, { weekEvents.set(event.id, {
...event, ...event,
startIdx: dayIndex, startIdx: dayIndex,
endIdx: dayIndex endIdx: dayIndex,
}) })
} else { } else {
const existing = weekEvents.get(event.id) const existing = weekEvents.get(event.id)
@ -459,12 +474,12 @@ const eventSpans = computed(() => {
// Generate repeat occurrences for this day // Generate repeat occurrences for this day
const repeatOccurrences = generateRepeatOccurrencesForDate(day.date) const repeatOccurrences = generateRepeatOccurrencesForDate(day.date)
repeatOccurrences.forEach(event => { repeatOccurrences.forEach((event) => {
if (!weekEvents.has(event.id)) { if (!weekEvents.has(event.id)) {
weekEvents.set(event.id, { weekEvents.set(event.id, {
...event, ...event,
startIdx: dayIndex, startIdx: dayIndex,
endIdx: dayIndex endIdx: dayIndex,
}) })
} else { } else {
const existing = weekEvents.get(event.id) const existing = weekEvents.get(event.id)
@ -495,7 +510,7 @@ const eventSpans = computed(() => {
// Assign rows to avoid overlaps // Assign rows to avoid overlaps
const rowsLastEnd = [] const rowsLastEnd = []
eventArray.forEach(event => { eventArray.forEach((event) => {
let placedRow = 0 let placedRow = 0
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) { while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
placedRow++ placedRow++

View File

@ -13,14 +13,14 @@ export const useCalendarStore = defineStore('calendar', {
config: { config: {
select_days: 1000, select_days: 1000,
min_year: MIN_YEAR, min_year: MIN_YEAR,
max_year: MAX_YEAR max_year: MAX_YEAR,
} },
}), }),
getters: { getters: {
// Basic configuration getters // Basic configuration getters
minYear: () => MIN_YEAR, minYear: () => MIN_YEAR,
maxYear: () => MAX_YEAR maxYear: () => MAX_YEAR,
}, },
actions: { actions: {
@ -49,13 +49,20 @@ export const useCalendarStore = defineStore('calendar', {
title: eventData.title, title: eventData.title,
startDate: eventData.startDate, startDate: eventData.startDate,
endDate: eventData.endDate, endDate: eventData.endDate,
colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), colorId:
startTime: singleDay ? (eventData.startTime || '09:00') : null, eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null, startTime: singleDay ? eventData.startTime || '09:00' : null,
repeat: eventData.repeat || 'none', 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', repeatCount: eventData.repeatCount || 'unlimited',
repeatWeekdays: eventData.repeatWeekdays, repeatWeekdays: eventData.repeatWeekdays,
isRepeating: (eventData.repeat && eventData.repeat !== 'none') isRepeating: eventData.repeat && eventData.repeat !== 'none',
} }
const startDate = new Date(fromLocalString(event.startDate)) 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 }) this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
} }
// No physical expansion; repeats are virtual
return event.id return event.id
}, },
getEventById(id) { getEventById(id) {
for (const [, list] of this.events) { for (const [, list] of this.events) {
const found = list.find(e => e.id === id) const found = list.find((e) => e.id === id)
if (found) return found if (found) return found
} }
return null return null
@ -110,7 +118,7 @@ export const useCalendarStore = defineStore('calendar', {
deleteEvent(eventId) { deleteEvent(eventId) {
const datesToCleanup = [] const datesToCleanup = []
for (const [dateStr, eventList] of this.events) { for (const [dateStr, eventList] of this.events) {
const eventIndex = eventList.findIndex(event => event.id === eventId) const eventIndex = eventList.findIndex((event) => event.id === eventId)
if (eventIndex !== -1) { if (eventIndex !== -1) {
eventList.splice(eventIndex, 1) eventList.splice(eventIndex, 1)
if (eventList.length === 0) { 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) { deleteSingleOccurrence(ctx) {
const { baseId, occurrenceIndex, weekday } = ctx const { baseId, occurrenceIndex } = ctx
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base || base.repeat !== 'weekly') return 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 // 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. // 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. // 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) this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
if (remaining === '0') return if (remaining === '0') return
// Find date of next occurrence // Find date of next occurrence
@ -145,9 +157,9 @@ export const useCalendarStore = defineStore('calendar', {
startDate: nextStartStr, startDate: nextStartStr,
endDate: nextStartStr, endDate: nextStartStr,
colorId: base.colorId, colorId: base.colorId,
repeat: 'weekly', repeat: 'weeks',
repeatCount: remaining, 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)) const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000))
let newStart = null let newStart = null
if (base.repeat === 'weekly' && base.repeatWeekdays) { if (base.repeat === 'weeks' && base.repeatWeekdays) {
const probe = new Date(oldStart) 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) 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') { } else if (base.repeat === 'months') {
newStart = new Date(oldStart)
newStart.setDate(newStart.getDate() + 14)
} else if (base.repeat === 'monthly') {
newStart = new Date(oldStart) newStart = new Date(oldStart)
newStart.setMonth(newStart.getMonth() + 1) newStart.setMonth(newStart.getMonth() + 1)
} else if (base.repeat === 'yearly') {
newStart = new Date(oldStart)
newStart.setFullYear(newStart.getFullYear() + 1)
} else { } else {
// Unknown pattern: delete entire series // Unknown pattern: delete entire series
this.deleteEvent(baseId) this.deleteEvent(baseId)
@ -207,63 +217,7 @@ export const useCalendarStore = defineStore('calendar', {
newEnd.setDate(newEnd.getDate() + spanDays) newEnd.setDate(newEnd.getDate() + spanDays)
base.startDate = toLocalString(newStart) base.startDate = toLocalString(newStart)
base.endDate = toLocalString(newEnd) base.endDate = toLocalString(newEnd)
// Reindex across map // old occurrence expansion removed (series handled differently now)
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
const originalRepeatCount = base.repeatCount const originalRepeatCount = base.repeatCount
// Always cap original series at the split occurrence index (occurrences 0..index-1) // Always cap original series at the split occurrence index (occurrences 0..index-1)
// Keep its weekday pattern unchanged. // Keep its weekday pattern unchanged.
@ -284,12 +238,12 @@ export const useCalendarStore = defineStore('calendar', {
// Handle weekdays for weekly repeats // Handle weekdays for weekly repeats
let newRepeatWeekdays = base.repeatWeekdays let newRepeatWeekdays = base.repeatWeekdays
if (base.repeat === 'weekly' && base.repeatWeekdays) { if (base.repeat === 'weeks' && base.repeatWeekdays) {
const newStartDate = new Date(fromLocalString(startDate)) const newStartDate = new Date(fromLocalString(startDate))
let dayShift = 0 let dayShift = 0
if (grabbedWeekday != null) { if (grabbedWeekday != null) {
// Rotate so that the grabbed weekday maps to the new start weekday // Rotate so that the grabbed weekday maps to the new start weekday
dayShift = newStartDate.getDay() - grabbedWeekday dayShift = newStartDate.getDay() - grabbedWeekday
} else { } else {
// Fallback: rotate by difference between new and original start weekday // Fallback: rotate by difference between new and original start weekday
const originalStartDate = new Date(fromLocalString(base.startDate)) const originalStartDate = new Date(fromLocalString(base.startDate))
@ -315,16 +269,15 @@ export const useCalendarStore = defineStore('calendar', {
colorId: base.colorId, colorId: base.colorId,
repeat: base.repeat, repeat: base.repeat,
repeatCount: newRepeatCount, repeatCount: newRepeatCount,
repeatWeekdays: newRepeatWeekdays repeatWeekdays: newRepeatWeekdays,
}) })
return newId return newId
}, },
_snapshotBaseEvent(eventId) { _snapshotBaseEvent(eventId) {
// Return a shallow snapshot of any instance for metadata // Return a shallow snapshot of any instance for metadata
for (const [, eventList] of this.events) { 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 } if (e) return { ...e }
} }
return null return null
@ -350,7 +303,7 @@ export const useCalendarStore = defineStore('calendar', {
id: eventId, id: eventId,
startDate, startDate,
endDate, endDate,
isSpanning: multi isSpanning: multi,
} }
// Normalize single-day time fields // Normalize single-day time fields
if (!multi) { 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) { _reindexBaseEvent(eventId, snapshot, startDate, endDate) {
if (!snapshot) return if (!snapshot) return
this._removeEventFromAllDatesById(eventId) this._removeEventFromAllDatesById(eventId)
@ -393,7 +438,7 @@ export const useCalendarStore = defineStore('calendar', {
_findEventInAnyList(eventId) { _findEventInAnyList(eventId) {
for (const [, eventList] of this.events) { 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 if (found) return found
} }
return null return null
@ -414,62 +459,6 @@ export const useCalendarStore = defineStore('calendar', {
} }
}, },
getEventById(id) { // NOTE: legacy dynamic getEventById for synthetic occurrences removed.
// 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
}
}
}) })