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

View File

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

View File

@ -33,7 +33,7 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import {
getLocalizedWeekdayNames,
getLocaleFirstDay,
@ -44,7 +44,10 @@ import {
const model = defineModel({
type: Array,
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({
weekend: { type: Array, default: undefined },
@ -55,12 +58,11 @@ const props = defineProps({
firstDay: { type: Number, default: null },
})
// If external model provided is entirely false, keep as-is (user will see fallback styling),
// only overwrite if null/undefined.
if (!model.value) model.value = [...props.fallback]
// Initialize internal from external if it has any true; else keep empty (fallback handled on emit)
if (model.value?.some?.(Boolean)) internal.value = [...model.value]
const labelsMondayFirst = getLocalizedWeekdayNames()
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 localeWeekend = getLocaleWeekendDays()
const firstDay = computed(() => (props.firstDay ?? localeFirst) % 7)
@ -71,10 +73,38 @@ const weekendDays = computed(() => {
})
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 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
const orderIndices = computed(() => Array.from({ length: 7 }, (_, i) => (i + firstDay.value) % 7))
@ -135,8 +165,8 @@ function isPressing(di) {
}
function onPointerDown(di) {
originalValues = [...model.value]
dragVal.value = !model.value[(di + firstDay.value) % 7]
originalValues = [...internal.value]
dragVal.value = !internal.value[(di + firstDay.value) % 7]
dragStart.value = di
previewEnd.value = di
dragging.value = true
@ -155,7 +185,8 @@ function onPointerUp() {
// simple click: toggle single
const next = [...originalValues]
next[(dragStart.value + firstDay.value) % 7] = dragVal.value
model.value = next
internal.value = next
emitExternal()
cleanupDrag()
} else {
commitDrag()
@ -169,7 +200,8 @@ function commitDrag() {
: [previewEnd.value, dragStart.value]
const next = [...originalValues]
for (let di = s; di <= e; di++) next[orderIndices.value[di]] = dragVal.value
model.value = next
internal.value = next
emitExternal()
cleanupDrag()
}
function cancelDrag() {
@ -185,14 +217,15 @@ function cleanupDrag() {
function toggleWeekend(work) {
const base = weekendDays.value
const target = work ? base : base.map((v) => !v)
const current = model.value
const current = internal.value
const allOn = current.every(Boolean)
const isTargetActive = current.every((v, i) => v === target[i])
if (allOn || isTargetActive) {
model.value = [false, false, false, false, false, false, false]
internal.value = [false, false, false, false, false, false, false]
} else {
model.value = [...target]
internal.value = [...target]
}
emitExternal()
}
</script>

View File

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

View File

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

View File

@ -81,9 +81,14 @@ function countPatternDaysInInterval(startDate, endDate, patternArr) {
}
// Recurrence: Weekly ------------------------------------------------------
function _getRecur(event) {
return event && event.recur ? event.recur : null
}
function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat !== 'weeks') return null
const pattern = event.repeatWeekdays || []
const recur = _getRecur(event)
if (!recur || recur.freq !== 'weeks') return null
const pattern = recur.weekdays || []
if (!pattern.some(Boolean)) return null
const target = fromLocalString(dateStr, timeZone)
@ -93,7 +98,7 @@ function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
const dow = dateFns.getDay(target)
if (!pattern[dow]) return null // target not active
const interval = event.repeatInterval || 1
const interval = recur.interval || 1
const baseBlockStart = getMondayOfISOWeek(baseStart, timeZone)
const currentBlockStart = getMondayOfISOWeek(target, timeZone)
// 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)
if (weekDiff === 0) {
let n = countPatternDaysInInterval(baseStart, target, pattern) - 1
if (!baseCountsAsPattern) n += 1 // Shift indices so base occurrence stays 0
return n < 0 || n >= event.repeatCount ? null : n
if (!baseCountsAsPattern) n += 1
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
return n < 0 || n >= maxCount ? null : n
}
const baseWeekEnd = dateFns.addDays(baseBlockStart, 6)
@ -120,42 +126,48 @@ function getWeeklyOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
const currentWeekCount = countPatternDaysInInterval(currentBlockStart, target, pattern)
let n = firstWeekCount + middleWeeksCount + currentWeekCount - 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 -----------------------------------------------------
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 d = fromLocalString(dateStr, timeZone)
const diffMonths = dateFns.differenceInCalendarMonths(d, baseStart)
if (diffMonths < 0) return null
const interval = event.repeatInterval || 1
const interval = recur.interval || 1
if (diffMonths % interval !== 0) return null
const baseDay = dateFns.getDate(baseStart)
const effectiveDay = Math.min(baseDay, dateFns.getDaysInMonth(d))
if (dateFns.getDate(d) !== effectiveDay) return null
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) {
if (!event?.isRepeating || event.repeat === 'none') return null
const recur = _getRecur(event)
if (!recur) return null
if (dateStr < event.startDate) return null
if (event.repeat === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
if (event.repeat === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
if (recur.freq === 'weeks') return getWeeklyOccurrenceIndex(event, dateStr, timeZone)
if (recur.freq === 'months') return getMonthlyOccurrenceIndex(event, dateStr, timeZone)
return null
}
// 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.
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 (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null
const pattern = event.repeatWeekdays || []
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
if (occurrenceIndex >= maxCount) return null
const pattern = recur.weekdays || []
if (!pattern.some(Boolean)) return null
const interval = event.repeatInterval || 1
const interval = recur.interval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
if (occurrenceIndex === 0) return toLocalString(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) {
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 (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null
const interval = event.repeatInterval || 1
const maxCount = recur.count === 'unlimited' ? Infinity : parseInt(recur.count, 10)
if (occurrenceIndex >= maxCount) return null
const interval = recur.interval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
const targetMonthOffset = occurrenceIndex * interval
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
@ -208,9 +222,10 @@ function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ)
}
function getOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat === 'none') return null
if (event.repeat === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
if (event.repeat === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
const recur = _getRecur(event)
if (!recur) return null
if (recur.freq === 'weeks') return getWeeklyOccurrenceDate(event, occurrenceIndex, timeZone)
if (recur.freq === 'months') return getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone)
return null
}