Recurrent/weekday input fixes. Refactored event store to use recur map rather than separate properties.

This commit is contained in:
Leo Vasanko 2025-08-25 22:04:04 -06:00
parent b69a299309
commit 29246af591
6 changed files with 235 additions and 148 deletions

View File

@ -29,6 +29,7 @@ const dialogMode = ref('create') // 'create' or 'edit'
const editingEventId = ref(null) const editingEventId = ref(null)
const unsavedCreateId = ref(null) const unsavedCreateId = ref(null)
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate } const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
const initialWeekday = ref(null)
const title = computed({ const title = computed({
get() { get() {
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) { if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
@ -63,10 +64,13 @@ function getStartingWeekday(selectionData = null) {
} }
const fallbackWeekdays = computed(() => { const fallbackWeekdays = computed(() => {
const startingDay = getStartingWeekday() let weekday = initialWeekday.value
const fallback = [false, false, false, false, false, false, false] if (weekday == null) {
fallback[startingDay] = true weekday = getStartingWeekday()
return fallback }
const fb = [false, false, false, false, false, false, false]
fb[weekday] = true
return fb
}) })
// Maps UI frequency display (including years) to store frequency (weeks/months only) // Maps UI frequency display (including years) to store frequency (weeks/months only)
@ -140,14 +144,24 @@ const selectedColor = computed({
const repeatCountBinding = computed({ const repeatCountBinding = computed({
get() { get() {
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) { if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
const rc = calendarStore.events.get(editingEventId.value).repeatCount const ev = calendarStore.events.get(editingEventId.value)
const rc = ev.recur?.count ?? 'unlimited'
return rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0 return rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
} }
return recurrenceOccurrences.value return recurrenceOccurrences.value
}, },
set(v) { set(v) {
if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) { if (editingEventId.value && calendarStore.events?.has(editingEventId.value)) {
calendarStore.events.get(editingEventId.value).repeatCount = v === 0 ? 'unlimited' : String(v) const ev = calendarStore.events.get(editingEventId.value)
if (!ev.recur && v !== 0) {
ev.recur = {
freq: recurrenceFrequency.value,
interval: recurrenceInterval.value,
count: 'unlimited',
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
}
}
if (ev.recur) ev.recur.count = v === 0 ? 'unlimited' : String(v)
calendarStore.touchEvents() calendarStore.touchEvents()
} }
recurrenceOccurrences.value = v recurrenceOccurrences.value = v
@ -178,14 +192,7 @@ const repeat = computed({
}) })
function buildStoreWeekdayPattern() { function buildStoreWeekdayPattern() {
let sunFirst = [...recurrenceWeekdays.value] return [...recurrenceWeekdays.value]
if (!sunFirst.some(Boolean)) {
const startingDay = getStartingWeekday()
sunFirst[startingDay] = true
}
return sunFirst
} }
function loadWeekdayPatternFromStore(storePattern) { function loadWeekdayPatternFromStore(storePattern) {
@ -222,6 +229,7 @@ function openCreateDialog(selectionData = null) {
} }
occurrenceContext.value = null occurrenceContext.value = null
initialWeekday.value = null
dialogMode.value = 'create' dialogMode.value = 'create'
recurrenceEnabled.value = false recurrenceEnabled.value = false
recurrenceInterval.value = 1 recurrenceInterval.value = 1
@ -234,17 +242,23 @@ function openCreateDialog(selectionData = null) {
const startingDay = getStartingWeekday({ start, end }) const startingDay = getStartingWeekday({ start, end })
recurrenceWeekdays.value[startingDay] = true recurrenceWeekdays.value[startingDay] = true
initialWeekday.value = startingDay
editingEventId.value = calendarStore.createEvent({ editingEventId.value = calendarStore.createEvent({
title: '', title: '',
startDate: start, startDate: start,
endDate: end, endDate: end,
colorId: colorId.value, colorId: colorId.value,
repeat: repeat.value, recur:
repeatInterval: recurrenceInterval.value, recurrenceEnabled.value && repeat.value !== 'none'
repeatCount: ? {
recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value), freq: recurrenceFrequency.value,
repeatWeekdays: buildStoreWeekdayPattern(), interval: recurrenceInterval.value,
count:
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value),
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
}
: null,
}) })
unsavedCreateId.value = editingEventId.value unsavedCreateId.value = editingEventId.value
@ -277,6 +291,7 @@ function openEditDialog(payload) {
unsavedCreateId.value = null unsavedCreateId.value = null
} }
occurrenceContext.value = null occurrenceContext.value = null
initialWeekday.value = null
if (!payload) return if (!payload) return
const baseId = payload.id const baseId = payload.id
@ -287,16 +302,16 @@ function openEditDialog(payload) {
const event = calendarStore.getEventById(baseId) const event = calendarStore.getEventById(baseId)
if (!event) return if (!event) return
if (event.isRepeating) { if (event.recur) {
if (event.repeat === 'weeks' && occurrenceIndex >= 0) { if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) {
const pattern = event.repeatWeekdays || [] const pattern = event.recur.weekdays || []
const baseStart = fromLocalString(event.startDate, DEFAULT_TZ) const baseStart = fromLocalString(event.startDate, DEFAULT_TZ)
const baseEnd = fromLocalString(event.endDate, DEFAULT_TZ) const baseEnd = fromLocalString(event.endDate, DEFAULT_TZ)
if (occurrenceIndex === 0) { if (occurrenceIndex === 0) {
occurrenceDate = baseStart occurrenceDate = baseStart
weekday = baseStart.getDay() weekday = baseStart.getDay()
} else { } else {
const interval = event.repeatInterval || 1 const interval = event.recur.interval || 1
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
const baseBlockStart = getMondayOfISOWeek(baseStart) const baseBlockStart = getMondayOfISOWeek(baseStart)
function isAligned(d) { function isAligned(d) {
@ -318,22 +333,24 @@ function openEditDialog(payload) {
occurrenceDate = cur occurrenceDate = cur
weekday = cur.getDay() weekday = cur.getDay()
} }
} else if (event.repeat === 'months' && occurrenceIndex >= 0) { } else if (event.recur.freq === 'months' && occurrenceIndex >= 0) {
const baseDate = fromLocalString(event.startDate, DEFAULT_TZ) const baseDate = fromLocalString(event.startDate, DEFAULT_TZ)
occurrenceDate = addMonths(baseDate, occurrenceIndex) occurrenceDate = addMonths(baseDate, occurrenceIndex)
} }
} }
dialogMode.value = 'edit' dialogMode.value = 'edit'
editingEventId.value = baseId editingEventId.value = baseId
loadWeekdayPatternFromStore(event.repeatWeekdays) loadWeekdayPatternFromStore(event.recur?.weekdays)
repeat.value = event.repeat // triggers setter mapping into recurrence state initialWeekday.value =
if (event.repeatInterval) recurrenceInterval.value = event.repeatInterval weekday != null ? weekday : fromLocalString(event.startDate, DEFAULT_TZ).getDay()
repeat.value = event.recur ? event.recur.freq : 'none'
if (event.recur?.interval) recurrenceInterval.value = event.recur.interval
// Set UI display frequency based on loaded data // Set UI display frequency based on loaded data
if (event.repeat === 'weeks') { if (event.recur?.freq === 'weeks') {
uiDisplayFrequency.value = 'weeks' uiDisplayFrequency.value = 'weeks'
} else if (event.repeat === 'months') { } else if (event.recur?.freq === 'months') {
if (event.repeatInterval && event.repeatInterval % 12 === 0 && event.repeatInterval >= 12) { if (event.recur.interval && event.recur.interval % 12 === 0 && event.recur.interval >= 12) {
uiDisplayFrequency.value = 'years' uiDisplayFrequency.value = 'years'
} else { } else {
uiDisplayFrequency.value = 'months' uiDisplayFrequency.value = 'months'
@ -342,15 +359,15 @@ function openEditDialog(payload) {
uiDisplayFrequency.value = 'weeks' uiDisplayFrequency.value = 'weeks'
} }
const rc = event.repeatCount ?? 'unlimited' const rc = event.recur?.count ?? 'unlimited'
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0 recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
colorId.value = event.colorId colorId.value = event.colorId
eventSaved.value = false eventSaved.value = false
if (event.isRepeating) { if (event.recur) {
if (event.repeat === 'weeks' && occurrenceIndex >= 0) { if (event.recur.freq === 'weeks' && occurrenceIndex >= 0) {
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate } occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
} else if (event.repeat === 'months' && occurrenceIndex > 0) { } else if (event.recur.freq === 'months' && occurrenceIndex > 0) {
occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate } occurrenceContext.value = { baseId, occurrenceIndex, weekday: null, occurrenceDate }
} }
} }
@ -379,12 +396,17 @@ function updateEventInStore() {
if (calendarStore.events?.has(editingEventId.value)) { if (calendarStore.events?.has(editingEventId.value)) {
const event = calendarStore.events.get(editingEventId.value) const event = calendarStore.events.get(editingEventId.value)
event.colorId = colorId.value event.colorId = colorId.value
event.repeat = repeat.value if (recurrenceEnabled.value && repeat.value !== 'none') {
event.repeatInterval = recurrenceInterval.value event.recur = {
event.repeatWeekdays = buildStoreWeekdayPattern() freq: recurrenceFrequency.value,
event.repeatCount = interval: recurrenceInterval.value,
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value) count:
event.isRepeating = recurrenceEnabled.value && repeat.value !== 'none' recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value),
weekdays: recurrenceFrequency.value === 'weeks' ? buildStoreWeekdayPattern() : null,
}
} else {
event.recur = null
}
calendarStore.touchEvents() calendarStore.touchEvents()
} }
} }
@ -468,11 +490,9 @@ const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceC
const isLastOccurrence = computed(() => { const isLastOccurrence = computed(() => {
if (!occurrenceContext.value || !editingEventId.value) return false if (!occurrenceContext.value || !editingEventId.value) return false
const event = calendarStore.getEventById(editingEventId.value) const event = calendarStore.getEventById(editingEventId.value)
if (!event || !event.isRepeating) return false if (!event || !event.recur) return false
if (event.recur.count === 'unlimited' || recurrenceOccurrences.value === 0) return false
if (event.repeatCount === 'unlimited' || recurrenceOccurrences.value === 0) return false const totalCount = parseInt(event.recur.count, 10) || 0
const totalCount = parseInt(event.repeatCount, 10) || 0
return occurrenceContext.value.occurrenceIndex === totalCount - 1 return occurrenceContext.value.occurrenceIndex === totalCount - 1
}) })
const formattedOccurrenceShort = computed(() => { const formattedOccurrenceShort = computed(() => {

View File

@ -176,11 +176,11 @@ function startLocalDrag(init, evt) {
const baseEv = store.getEventById(init.id) const baseEv = store.getEventById(init.id)
if ( if (
baseEv && baseEv &&
baseEv.isRepeating && baseEv.recur &&
baseEv.repeat === 'weeks' && baseEv.recur.freq === 'weeks' &&
Array.isArray(baseEv.repeatWeekdays) Array.isArray(baseEv.recur.weekdays)
) { ) {
originalPattern = [...baseEv.repeatWeekdays] originalPattern = [...baseEv.recur.weekdays]
} }
} catch {} } catch {}
} }
@ -286,8 +286,8 @@ function onDragPointerMove(e) {
const shift = currentWeekday - st.originalWeekday const shift = currentWeekday - st.originalWeekday
const rotated = store._rotateWeekdayPattern([...st.originalPattern], shift) const rotated = store._rotateWeekdayPattern([...st.originalPattern], shift)
const ev = store.getEventById(st.id) const ev = store.getEventById(st.id)
if (ev && ev.repeat === 'weeks') { if (ev && ev.recur && ev.recur.freq === 'weeks') {
ev.repeatWeekdays = rotated ev.recur.weekdays = rotated
store.touchEvents() store.touchEvents()
} }
} catch {} } catch {}

View File

@ -33,7 +33,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
import { import {
getLocalizedWeekdayNames, getLocalizedWeekdayNames,
getLocaleFirstDay, getLocaleFirstDay,
@ -44,7 +44,10 @@ import {
const model = defineModel({ const model = defineModel({
type: Array, type: Array,
default: () => [false, false, false, false, false, false, false], default: () => [false, false, false, false, false, false, false],
}) }) // external value consumers see
// Internal state preserves the user's explicit picks even if all false
const internal = ref([false, false, false, false, false, false, false])
const props = defineProps({ const props = defineProps({
weekend: { type: Array, default: undefined }, weekend: { type: Array, default: undefined },
@ -55,12 +58,11 @@ const props = defineProps({
firstDay: { type: Number, default: null }, firstDay: { type: Number, default: null },
}) })
// If external model provided is entirely false, keep as-is (user will see fallback styling), // Initialize internal from external if it has any true; else keep empty (fallback handled on emit)
// only overwrite if null/undefined. if (model.value?.some?.(Boolean)) internal.value = [...model.value]
if (!model.value) model.value = [...props.fallback]
const labelsMondayFirst = getLocalizedWeekdayNames() const labelsMondayFirst = getLocalizedWeekdayNames()
const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)] const labels = [labelsMondayFirst[6], ...labelsMondayFirst.slice(0, 6)]
const anySelected = computed(() => model.value.some(Boolean)) const anySelected = computed(() => internal.value.some(Boolean))
const localeFirst = getLocaleFirstDay() const localeFirst = getLocaleFirstDay()
const localeWeekend = getLocaleWeekendDays() const localeWeekend = getLocaleWeekendDays()
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7) const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
@ -71,10 +73,38 @@ const weekendDays = computed(() => {
}) })
const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value)) const displayLabels = computed(() => reorderByFirstDay(labels, firstDay.value))
const displayValuesCommitted = computed(() => reorderByFirstDay(model.value, firstDay.value)) const displayValuesCommitted = computed(() => reorderByFirstDay(internal.value, firstDay.value))
const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value)) const displayWorking = computed(() => reorderByFirstDay(weekendDays.value, firstDay.value))
const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value)) const displayDefault = computed(() => reorderByFirstDay(props.fallback, firstDay.value))
// Expose a normalized pattern (Sunday-first) that substitutes the fallback day if none selected.
// This keeps UI visually showing fallback (muted) but downstream logic can opt-in by reading this.
function computeFallbackPattern() {
const fb = props.fallback && props.fallback.length === 7 ? props.fallback : null
if (fb && fb.some(Boolean)) return [...fb]
const arr = [false, false, false, false, false, false, false]
const idx = fb ? fb.findIndex(Boolean) : -1
if (idx >= 0) arr[idx] = true
else arr[0] = true
return arr
}
function emitExternal() {
model.value = internal.value.some(Boolean) ? [...internal.value] : computeFallbackPattern()
}
emitExternal()
watch(
() => model.value,
(nv) => {
if (!nv) return
if (!nv.some(Boolean)) return
const fb = computeFallbackPattern()
const isFallback = fb.every((v, i) => v === nv[i])
// If internal is empty and model only reflects fallback, do not sync into internal
if (isFallback && !internal.value.some(Boolean)) return
internal.value = [...nv]
},
)
// Mapping from display index to original model index // Mapping from display index to original model index
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7)) const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
@ -135,8 +165,8 @@ function isPressing(di) {
} }
function onPointerDown(di) { function onPointerDown(di) {
originalValues = [...model.value] originalValues = [...internal.value]
dragVal.value = !model.value[(di + firstDay.value) % 7] dragVal.value = !internal.value[(di + firstDay.value) % 7]
dragStart.value = di dragStart.value = di
previewEnd.value = di previewEnd.value = di
dragging.value = true dragging.value = true
@ -155,7 +185,8 @@ function onPointerUp() {
// simple click: toggle single // simple click: toggle single
const next = [...originalValues] const next = [...originalValues]
next[(dragStart.value + firstDay.value) % 7] = dragVal.value next[(dragStart.value + firstDay.value) % 7] = dragVal.value
model.value = next internal.value = next
emitExternal()
cleanupDrag() cleanupDrag()
} else { } else {
commitDrag() commitDrag()
@ -169,7 +200,8 @@ function commitDrag() {
: [previewEnd.value, dragStart.value] : [previewEnd.value, dragStart.value]
const next = [...originalValues] const next = [...originalValues]
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
model.value = next internal.value = next
emitExternal()
cleanupDrag() cleanupDrag()
} }
function cancelDrag() { function cancelDrag() {
@ -185,14 +217,15 @@ function cleanupDrag() {
function toggleWeekend(work) { function toggleWeekend(work) {
const base = weekendDays.value const base = weekendDays.value
const target = work ? base : base.map((v) => !v) const target = work ? base : base.map((v) => !v)
const current = model.value const current = internal.value
const allOn = current.every(Boolean) const allOn = current.every(Boolean)
const isTargetActive = current.every((v, i) => v === target[i]) const isTargetActive = current.every((v, i) => v === target[i])
if (allOn || isTargetActive) { if (allOn || isTargetActive) {
model.value = [false, false, false, false, false, false, false] internal.value = [false, false, false, false, false, false, false]
} else { } else {
model.value = [...target] internal.value = [...target]
} }
emitExternal()
} }
</script> </script>

View File

@ -65,14 +65,14 @@ export function createVirtualWeekManager({
const repeatingBases = [] const repeatingBases = []
if (calendarStore.events) { if (calendarStore.events) {
for (const ev of calendarStore.events.values()) { for (const ev of calendarStore.events.values()) {
if (ev.isRepeating) repeatingBases.push(ev) if (ev.recur) repeatingBases.push(ev)
} }
} }
const collectEventsForDate = (dateStr, curDateObj) => { const collectEventsForDate = (dateStr, curDateObj) => {
const storedEvents = [] const storedEvents = []
for (const ev of calendarStore.events.values()) { for (const ev of calendarStore.events.values()) {
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) { if (!ev.recur && dateStr >= ev.startDate && dateStr <= ev.endDate) {
storedEvents.push(ev) storedEvents.push(ev)
} }
} }
@ -289,7 +289,7 @@ export function createVirtualWeekManager({
if (!visibleWeeks.value.length) return if (!visibleWeeks.value.length) return
const repeatingBases = [] const repeatingBases = []
if (calendarStore.events) { if (calendarStore.events) {
for (const ev of calendarStore.events.values()) if (ev.isRepeating) repeatingBases.push(ev) for (const ev of calendarStore.events.values()) if (ev.recur) repeatingBases.push(ev)
} }
const selStart = selection.value.startDate const selStart = selection.value.startDate
const selCount = selection.value.dayCount const selCount = selection.value.dayCount
@ -303,7 +303,7 @@ export function createVirtualWeekManager({
// Rebuild events list for this day // Rebuild events list for this day
const storedEvents = [] const storedEvents = []
for (const ev of calendarStore.events.values()) { for (const ev of calendarStore.events.values()) {
if (!ev.isRepeating && dateStr >= ev.startDate && dateStr <= ev.endDate) { if (!ev.recur && dateStr >= ev.startDate && dateStr <= ev.endDate) {
storedEvents.push(ev) storedEvents.push(ev)
} }
} }

View File

@ -137,11 +137,17 @@ export const useCalendarStore = defineStore('calendar', {
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
startTime: singleDay ? eventData.startTime || '09:00' : null, startTime: singleDay ? eventData.startTime || '09:00' : null,
durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, durationMinutes: singleDay ? eventData.durationMinutes || 60 : null,
repeat: ['weeks', 'months'].includes(eventData.repeat) ? eventData.repeat : 'none', recur:
repeatInterval: eventData.repeatInterval || 1, eventData.recur && ['weeks', 'months'].includes(eventData.recur.freq)
repeatCount: eventData.repeatCount || 'unlimited', ? {
repeatWeekdays: eventData.repeatWeekdays, freq: eventData.recur.freq,
isRepeating: eventData.repeat && eventData.repeat !== 'none', interval: eventData.recur.interval || 1,
count: eventData.recur.count ?? 'unlimited',
weekdays: Array.isArray(eventData.recur.weekdays)
? [...eventData.recur.weekdays]
: null,
}
: null,
} }
this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate }) this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
this.notifyEventsChanged() this.notifyEventsChanged()
@ -181,12 +187,12 @@ export const useCalendarStore = defineStore('calendar', {
deleteFirstOccurrence(baseId) { deleteFirstOccurrence(baseId) {
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base) return if (!base) return
if (!base.isRepeating) { if (!base.recur) {
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
const numericCount = const numericCount =
base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10) base.recur.count === 'unlimited' ? Infinity : parseInt(base.recur.count, 10)
if (numericCount <= 1) { if (numericCount <= 1) {
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
@ -205,7 +211,7 @@ export const useCalendarStore = defineStore('calendar', {
) )
base.startDate = nextStartStr base.startDate = nextStartStr
base.endDate = newEndStr base.endDate = newEndStr
if (numericCount !== Infinity) base.repeatCount = String(Math.max(1, numericCount - 1)) if (numericCount !== Infinity) base.recur.count = String(Math.max(1, numericCount - 1))
this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate }) this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate })
this.notifyEventsChanged() this.notifyEventsChanged()
}, },
@ -215,7 +221,7 @@ export const useCalendarStore = defineStore('calendar', {
if (occurrenceIndex == null) return if (occurrenceIndex == null) return
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base) return if (!base) return
if (!base.isRepeating) { if (!base.recur) {
if (occurrenceIndex === 0) this.deleteEvent(baseId) if (occurrenceIndex === 0) this.deleteEvent(baseId)
return return
} }
@ -224,7 +230,8 @@ export const useCalendarStore = defineStore('calendar', {
return return
} }
const snapshot = { ...base } const snapshot = { ...base }
base.repeatCount = occurrenceIndex snapshot.recur = snapshot.recur ? { ...snapshot.recur } : null
base.recur.count = occurrenceIndex
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ) const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
if (!nextStartStr) return if (!nextStartStr) return
const durationDays = Math.max( const durationDays = Math.max(
@ -236,7 +243,7 @@ export const useCalendarStore = defineStore('calendar', {
) )
const newEndStr = toLocalString(addDays(fromLocalString(nextStartStr), durationDays)) const newEndStr = toLocalString(addDays(fromLocalString(nextStartStr), durationDays))
const originalNumeric = const originalNumeric =
snapshot.repeatCount === 'unlimited' ? Infinity : parseInt(snapshot.repeatCount, 10) snapshot.recur.count === 'unlimited' ? Infinity : parseInt(snapshot.recur.count, 10)
let remainingCount = 'unlimited' let remainingCount = 'unlimited'
if (originalNumeric !== Infinity) { if (originalNumeric !== Infinity) {
const rem = originalNumeric - (occurrenceIndex + 1) const rem = originalNumeric - (occurrenceIndex + 1)
@ -248,10 +255,14 @@ export const useCalendarStore = defineStore('calendar', {
startDate: nextStartStr, startDate: nextStartStr,
endDate: newEndStr, endDate: newEndStr,
colorId: snapshot.colorId, colorId: snapshot.colorId,
repeat: snapshot.repeat, recur: snapshot.recur
repeatInterval: snapshot.repeatInterval, ? {
repeatCount: remainingCount, freq: snapshot.recur.freq,
repeatWeekdays: snapshot.repeatWeekdays, interval: snapshot.recur.interval,
count: remainingCount,
weekdays: snapshot.recur.weekdays ? [...snapshot.recur.weekdays] : null,
}
: null,
}) })
this.notifyEventsChanged() this.notifyEventsChanged()
}, },
@ -259,7 +270,7 @@ export const useCalendarStore = defineStore('calendar', {
deleteFromOccurrence(ctx) { deleteFromOccurrence(ctx) {
const { baseId, occurrenceIndex } = ctx const { baseId, occurrenceIndex } = ctx
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base || !base.isRepeating) return if (!base || !base.recur) return
if (occurrenceIndex === 0) { if (occurrenceIndex === 0) {
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
@ -288,15 +299,15 @@ export const useCalendarStore = defineStore('calendar', {
if ( if (
rotatePattern && rotatePattern &&
(mode === 'move' || mode === 'resize-left') && (mode === 'move' || mode === 'resize-left') &&
snapshot.isRepeating && snapshot.recur &&
snapshot.repeat === 'weeks' && snapshot.recur.freq === 'weeks' &&
Array.isArray(snapshot.repeatWeekdays) Array.isArray(snapshot.recur.weekdays)
) { ) {
const oldDow = prevStart.getDay() const oldDow = prevStart.getDay()
const newDow = newStart.getDay() const newDow = newStart.getDay()
const shift = newDow - oldDow const shift = newDow - oldDow
if (shift !== 0) { if (shift !== 0) {
snapshot.repeatWeekdays = this._rotateWeekdayPattern(snapshot.repeatWeekdays, shift) snapshot.recur.weekdays = this._rotateWeekdayPattern(snapshot.recur.weekdays, shift)
} }
} }
this.events.set(eventId, { ...snapshot, isSpanning: snapshot.startDate < snapshot.endDate }) this.events.set(eventId, { ...snapshot, isSpanning: snapshot.startDate < snapshot.endDate })
@ -305,8 +316,8 @@ export const useCalendarStore = defineStore('calendar', {
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) { splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
const base = this.events.get(baseId) const base = this.events.get(baseId)
if (!base || !base.isRepeating) return if (!base || !base.recur) return
const originalCountRaw = base.repeatCount const originalCountRaw = base.recur.count
const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ) const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
// If series effectively has <=1 occurrence, treat as simple move (no split) and flatten // If series effectively has <=1 occurrence, treat as simple move (no split) and flatten
@ -317,9 +328,8 @@ export const useCalendarStore = defineStore('calendar', {
} }
if (totalOccurrences <= 1) { if (totalOccurrences <= 1) {
// Flatten to non-repeating if not already // Flatten to non-repeating if not already
if (base.isRepeating) { if (base.recur) {
base.repeat = 'none' base.recur = null
base.isRepeating = false
this.events.set(baseId, { ...base }) this.events.set(baseId, { ...base })
} }
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move', rotatePattern: true }) this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move', rotatePattern: true })
@ -330,9 +340,9 @@ export const useCalendarStore = defineStore('calendar', {
return baseId return baseId
} }
let keptOccurrences = 0 let keptOccurrences = 0
if (base.repeat === 'weeks') { if (base.recur.freq === 'weeks') {
const interval = base.repeatInterval || 1 const interval = base.recur.interval || 1
const pattern = base.repeatWeekdays || [] const pattern = base.recur.weekdays || []
if (!pattern.some(Boolean)) return if (!pattern.some(Boolean)) return
const WEEK_MS = 7 * 86400000 const WEEK_MS = 7 * 86400000
const blockStartBase = getMondayOfISOWeek(baseStart) const blockStartBase = getMondayOfISOWeek(baseStart)
@ -346,11 +356,11 @@ export const useCalendarStore = defineStore('calendar', {
if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++ if (pattern[cursor.getDay()] && isAligned(cursor)) keptOccurrences++
cursor = addDays(cursor, 1) cursor = addDays(cursor, 1)
} }
} else if (base.repeat === 'months') { } else if (base.recur.freq === 'months') {
const diffMonths = const diffMonths =
(occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 + (occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 +
(occurrenceDate.getMonth() - baseStart.getMonth()) (occurrenceDate.getMonth() - baseStart.getMonth())
const interval = base.repeatInterval || 1 const interval = base.recur.interval || 1
if (diffMonths <= 0 || diffMonths % interval !== 0) return if (diffMonths <= 0 || diffMonths % interval !== 0) return
keptOccurrences = diffMonths keptOccurrences = diffMonths
} else { } else {
@ -359,7 +369,12 @@ export const useCalendarStore = defineStore('calendar', {
this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences) this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences)
// After truncation compute base kept count // After truncation compute base kept count
const truncated = this.events.get(baseId) const truncated = this.events.get(baseId)
if (truncated && truncated.repeatCount && truncated.repeatCount !== 'unlimited') { if (
truncated &&
truncated.recur &&
truncated.recur.count &&
truncated.recur.count !== 'unlimited'
) {
// keptOccurrences already reflects number before split; adjust not needed further // keptOccurrences already reflects number before split; adjust not needed further
} }
let remainingCount = 'unlimited' let remainingCount = 'unlimited'
@ -371,13 +386,13 @@ export const useCalendarStore = defineStore('calendar', {
remainingCount = String(rem) remainingCount = String(rem)
} }
} }
let repeatWeekdays = base.repeatWeekdays let weekdays = base.recur.weekdays
if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) { if (base.recur.freq === 'weeks' && Array.isArray(base.recur.weekdays)) {
const origWeekday = occurrenceDate.getDay() const origWeekday = occurrenceDate.getDay()
const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay() const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay()
const shift = newWeekday - origWeekday const shift = newWeekday - origWeekday
if (shift !== 0) { if (shift !== 0) {
repeatWeekdays = this._rotateWeekdayPattern(base.repeatWeekdays, shift) weekdays = this._rotateWeekdayPattern(base.recur.weekdays, shift)
} }
} }
const newId = this.createEvent({ const newId = this.createEvent({
@ -385,29 +400,29 @@ export const useCalendarStore = defineStore('calendar', {
startDate: newStartStr, startDate: newStartStr,
endDate: newEndStr, endDate: newEndStr,
colorId: base.colorId, colorId: base.colorId,
repeat: base.repeat, recur: {
repeatInterval: base.repeatInterval, freq: base.recur.freq,
repeatCount: remainingCount, interval: base.recur.interval,
repeatWeekdays, count: remainingCount,
weekdays,
},
}) })
// Flatten base if single occurrence now // Flatten base if single occurrence now
if (truncated && truncated.isRepeating) { if (truncated && truncated.recur) {
const baseCountNum = const baseCountNum =
truncated.repeatCount === 'unlimited' ? Infinity : parseInt(truncated.repeatCount, 10) truncated.recur.count === 'unlimited' ? Infinity : parseInt(truncated.recur.count, 10)
if (baseCountNum <= 1) { if (baseCountNum <= 1) {
truncated.repeat = 'none' truncated.recur = null
truncated.isRepeating = false
this.events.set(baseId, { ...truncated }) this.events.set(baseId, { ...truncated })
} }
} }
// Flatten new if single occurrence only // Flatten new if single occurrence only
const newly = this.events.get(newId) const newly = this.events.get(newId)
if (newly && newly.isRepeating) { if (newly && newly.recur) {
const newCountNum = const newCountNum =
newly.repeatCount === 'unlimited' ? Infinity : parseInt(newly.repeatCount, 10) newly.recur.count === 'unlimited' ? Infinity : parseInt(newly.recur.count, 10)
if (newCountNum <= 1) { if (newCountNum <= 1) {
newly.repeat = 'none' newly.recur = null
newly.isRepeating = false
this.events.set(newId, { ...newly }) this.events.set(newId, { ...newly })
} }
} }
@ -417,8 +432,8 @@ export const useCalendarStore = defineStore('calendar', {
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) { splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) {
const base = this.events.get(baseId) const base = this.events.get(baseId)
if (!base || !base.isRepeating) return null if (!base || !base.recur) return null
const originalCountRaw = base.repeatCount const originalCountRaw = base.recur.count
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
let newSeriesCount = 'unlimited' let newSeriesCount = 'unlimited'
if (originalCountRaw !== 'unlimited') { if (originalCountRaw !== 'unlimited') {
@ -433,21 +448,25 @@ export const useCalendarStore = defineStore('calendar', {
startDate: newStartStr, startDate: newStartStr,
endDate: newEndStr, endDate: newEndStr,
colorId: base.colorId, colorId: base.colorId,
repeat: base.repeat, recur: base.recur
repeatInterval: base.repeatInterval, ? {
repeatCount: newSeriesCount, freq: base.recur.freq,
repeatWeekdays: base.repeatWeekdays, interval: base.recur.interval,
count: newSeriesCount,
weekdays: base.recur.weekdays ? [...base.recur.weekdays] : null,
}
: null,
}) })
}, },
_terminateRepeatSeriesAtIndex(baseId, index) { _terminateRepeatSeriesAtIndex(baseId, index) {
const ev = this.events.get(baseId) const ev = this.events.get(baseId)
if (!ev || !ev.isRepeating) return if (!ev || !ev.recur) return
if (ev.repeatCount === 'unlimited') { if (ev.recur.count === 'unlimited') {
ev.repeatCount = String(index) ev.recur.count = String(index)
} else { } else {
const rc = parseInt(ev.repeatCount, 10) const rc = parseInt(ev.recur.count, 10)
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index)) if (!isNaN(rc)) ev.recur.count = String(Math.min(rc, index))
} }
this.notifyEventsChanged() this.notifyEventsChanged()
}, },

View File

@ -81,9 +81,14 @@ function countPatternDaysInInterval(startDate, endDate, patternArr) {
} }
// Recurrence: Weekly ------------------------------------------------------ // Recurrence: Weekly ------------------------------------------------------
function _getRecur(event) {
return event && event.recur ? event.recur : null
}
function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat !== 'weeks') return null const recur = _getRecur(event)
const pattern = event.repeatWeekdays || [] if (!recur || recur.freq !== 'weeks') return null
const pattern = recur.weekdays || []
if (!pattern.some(Boolean)) return null if (!pattern.some(Boolean)) return null
const target = fromLocalString(dateStr, timeZone) const target = fromLocalString(dateStr, timeZone)
@ -93,7 +98,7 @@ function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
const dow = dateFns.getDay(target) const dow = dateFns.getDay(target)
if (!pattern[dow]) return null // target not active if (!pattern[dow]) return null // target not active
const interval = event.repeatInterval || 1 const interval = recur.interval || 1
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone) const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
const currentBlockStart = getMondayOfISOWeek(target, timeZone) const currentBlockStart = getMondayOfISOWeek(target, timeZone)
// Number of weeks between block starts (each block start is a Monday) // Number of weeks between block starts (each block start is a Monday)
@ -106,8 +111,9 @@ function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
// Same ISO week as base: count pattern days from baseStart up to target (inclusive) // Same ISO week as base: count pattern days from baseStart up to target (inclusive)
if (weekDiff === 0) { if (weekDiff === 0) {
let n = countPatternDaysInInterval(baseStart, target, pattern) - 1 let n = countPatternDaysInInterval(baseStart, target, pattern) - 1
if (!baseCountsAsPattern) n += 1 // Shift indices so base occurrence stays 0 if (!baseCountsAsPattern) n += 1
return n < 0 || n >= event.repeatCount ? null : n const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n < 0 || n >= maxCount ? null : n
} }
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6) const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
@ -120,42 +126,48 @@ function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern) const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1 let n = firstWeekCount + middleWeeksCount + currentWeekCount - 1
if (!baseCountsAsPattern) n += 1 if (!baseCountsAsPattern) n += 1
return n >= event.repeatCount ? null : n const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n >= maxCount ? null : n
} }
// Recurrence: Monthly ----------------------------------------------------- // Recurrence: Monthly -----------------------------------------------------
function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { function getMonthlyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat !== 'months') return null const recur = _getRecur(event)
if (!recur || recur.freq !== 'months') return null
const baseStart = fromLocalString(event.startDate, timeZone) const baseStart = fromLocalString(event.startDate, timeZone)
const d = fromLocalString(dateStr, timeZone) const d = fromLocalString(dateStr, timeZone)
const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart) const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
if (diffMonths < 0) return null if (diffMonths < 0) return null
const interval = event.repeatInterval || 1 const interval = recur.interval || 1
if (diffMonths % interval !== 0) return null if (diffMonths % interval !== 0) return null
const baseDay = dateFns.getDate(baseStart) const baseDay = dateFns.getDate(baseStart)
const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d)) const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
if (dateFns.getDate(d) !== effectiveDay) return null if (dateFns.getDate(d) !== effectiveDay) return null
const n = diffMonths / interval const n = diffMonths / interval
return n >= event.repeatCount ? null : n const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n >= maxCount ? null : n
} }
function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) { function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat === 'none') return null const recur = _getRecur(event)
if (!recur) return null
if (dateStr < event.startDate) return null if (dateStr < event.startDate) return null
if (event.repeat === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone) if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
if (event.repeat === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone) if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
return null return null
} }
// Reverse lookup: given a recurrence index (0-based) return the occurrence start date string. // Reverse lookup: given a recurrence index (0-based) return the occurrence start date string.
// Returns null if the index is out of range or the event is not repeating. // Returns null if the index is out of range or the event is not repeating.
function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat !== 'weeks') return null const recur = _getRecur(event)
if (!recur || recur.freq !== 'weeks') return null
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
const pattern = event.repeatWeekdays || [] if (occurrenceIndex >= maxCount) return null
const pattern = recur.weekdays || []
if (!pattern.some(Boolean)) return null if (!pattern.some(Boolean)) return null
const interval = event.repeatInterval || 1 const interval = recur.interval || 1
const baseStart = fromLocalString(event.startDate, timeZone) const baseStart = fromLocalString(event.startDate, timeZone)
if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone) if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone)
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone) const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
@ -192,10 +204,12 @@ function getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ)
} }
function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat !== 'months') return null const recur = _getRecur(event)
if (!recur || recur.freq !== 'months') return null
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
const interval = event.repeatInterval || 1 if (occurrenceIndex >= maxCount) return null
const interval = recur.interval || 1
const baseStart = fromLocalString(event.startDate, timeZone) const baseStart = fromLocalString(event.startDate, timeZone)
const targetMonthOffset = occurrenceIndex * interval const targetMonthOffset = occurrenceIndex * interval
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset) const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
@ -208,9 +222,10 @@ function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ)
} }
function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) { function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat === 'none') return null const recur = _getRecur(event)
if (event.repeat === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone) if (!recur) return null
if (event.repeat === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone) if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
return null return null
} }