vue #1
@ -19,7 +19,7 @@ const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occur
|
||||
const title = ref('')
|
||||
const recurrenceEnabled = ref(false)
|
||||
const recurrenceInterval = ref(1) // N in "Every N weeks/months"
|
||||
const recurrenceFrequency = ref('weeks') // 'weeks' | 'months' | 'years'
|
||||
const recurrenceFrequency = ref('weeks') // 'weeks' | 'months'
|
||||
const recurrenceWeekdays = ref([false, false, false, false, false, false, false])
|
||||
const recurrenceOccurrences = ref(0) // 0 = unlimited
|
||||
const colorId = ref(0)
|
||||
@ -42,31 +42,11 @@ const fallbackWeekdays = computed(() => {
|
||||
return fallback
|
||||
})
|
||||
|
||||
function preventFocusOnMouseDown(event) {
|
||||
// Prevent focus when clicking with mouse, but allow keyboard navigation
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
// Bridge legacy repeat API (store still expects repeat & repeatWeekdays)
|
||||
// Repeat mapping uses 'weeks' | 'months' | 'none' directly (legacy 'weekly'/'monthly' accepted on load)
|
||||
const repeat = computed({
|
||||
get() {
|
||||
if (!recurrenceEnabled.value) return 'none'
|
||||
if (recurrenceFrequency.value === 'weeks') {
|
||||
if (recurrenceInterval.value === 1) return 'weekly'
|
||||
if (recurrenceInterval.value === 2) return 'biweekly'
|
||||
// Fallback map >2 to weekly (future: custom)
|
||||
return 'weekly'
|
||||
} else if (recurrenceFrequency.value === 'months') {
|
||||
if (recurrenceInterval.value === 1) return 'monthly'
|
||||
if (recurrenceInterval.value === 12) return 'yearly'
|
||||
// Fallback map >1 to monthly
|
||||
return 'monthly'
|
||||
} else {
|
||||
// years (map to yearly via 12 * interval months)
|
||||
if (recurrenceInterval.value === 1) return 'yearly'
|
||||
// Multi-year -> treat as yearly (future: custom)
|
||||
return 'yearly'
|
||||
}
|
||||
return recurrenceFrequency.value // 'weeks' | 'months'
|
||||
},
|
||||
set(val) {
|
||||
if (val === 'none') {
|
||||
@ -74,27 +54,8 @@ const repeat = computed({
|
||||
return
|
||||
}
|
||||
recurrenceEnabled.value = true
|
||||
switch (val) {
|
||||
case 'weekly':
|
||||
recurrenceFrequency.value = 'weeks'
|
||||
recurrenceInterval.value = 1
|
||||
break
|
||||
case 'biweekly':
|
||||
recurrenceFrequency.value = 'weeks'
|
||||
recurrenceInterval.value = 2
|
||||
break
|
||||
case 'monthly':
|
||||
recurrenceFrequency.value = 'months'
|
||||
recurrenceInterval.value = 1
|
||||
break
|
||||
case 'yearly':
|
||||
recurrenceFrequency.value = 'years'
|
||||
recurrenceInterval.value = 1
|
||||
break
|
||||
default:
|
||||
recurrenceFrequency.value = 'weeks'
|
||||
recurrenceInterval.value = 1
|
||||
}
|
||||
if (val === 'weeks' || val === 'weekly') recurrenceFrequency.value = 'weeks'
|
||||
else if (val === 'months' || val === 'monthly') recurrenceFrequency.value = 'months'
|
||||
},
|
||||
})
|
||||
|
||||
@ -153,6 +114,7 @@ function openCreateDialog() {
|
||||
endDate: props.selection.end,
|
||||
colorId: colorId.value,
|
||||
repeat: repeat.value,
|
||||
repeatInterval: recurrenceInterval.value,
|
||||
repeatCount:
|
||||
recurrenceOccurrences.value === 0 ? 'for now' : String(recurrenceOccurrences.value),
|
||||
repeatWeekdays: buildStoreWeekdayPattern(),
|
||||
@ -204,6 +166,7 @@ function openEditDialog(eventInstanceId) {
|
||||
title.value = event.title
|
||||
loadWeekdayPatternFromStore(event.repeatWeekdays)
|
||||
repeat.value = event.repeat // triggers setter mapping into recurrence state
|
||||
if (event.repeatInterval) recurrenceInterval.value = event.repeatInterval
|
||||
// Map repeatCount
|
||||
const rc = event.repeatCount ?? 'unlimited'
|
||||
recurrenceOccurrences.value = rc === 'unlimited' ? 0 : parseInt(rc, 10) || 0
|
||||
@ -244,6 +207,7 @@ function updateEventInStore() {
|
||||
event.title = title.value
|
||||
event.colorId = colorId.value
|
||||
event.repeat = repeat.value
|
||||
event.repeatInterval = recurrenceInterval.value
|
||||
event.repeatWeekdays = buildStoreWeekdayPattern()
|
||||
event.repeatCount =
|
||||
recurrenceOccurrences.value === 0 ? 'unlimited' : String(recurrenceOccurrences.value)
|
||||
@ -304,7 +268,7 @@ watch([recurrenceEnabled, recurrenceInterval, recurrenceFrequency], () => {
|
||||
watch(
|
||||
recurrenceWeekdays,
|
||||
() => {
|
||||
if (editingEventId.value && showDialog.value && repeat.value === 'weekly') updateEventInStore()
|
||||
if (editingEventId.value && showDialog.value && repeat.value === 'weeks') updateEventInStore()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
@ -396,12 +360,6 @@ const finalOccurrenceDate = computed(() => {
|
||||
const d = new Date(start)
|
||||
d.setMonth(d.getMonth() + monthsToAdd)
|
||||
return d
|
||||
} else {
|
||||
// years
|
||||
const yearsToAdd = recurrenceInterval.value * (count - 1)
|
||||
const d = new Date(start)
|
||||
d.setFullYear(d.getFullYear() + yearsToAdd)
|
||||
return d
|
||||
}
|
||||
})
|
||||
|
||||
@ -427,21 +385,15 @@ const formattedFinalOccurrence = computed(() => {
|
||||
|
||||
const recurrenceSummary = computed(() => {
|
||||
if (!recurrenceEnabled.value) return 'Does not recur'
|
||||
const unit = recurrenceFrequency.value // weeks | months | years (plural)
|
||||
const singular = unit.slice(0, -1)
|
||||
const unitary = { weeks: 'Weekly', months: 'Monthly', years: 'Annually' }
|
||||
let base =
|
||||
recurrenceInterval.value > 1 ? `Every ${recurrenceInterval.value} ${unit}` : unitary[unit]
|
||||
if (recurrenceFrequency.value === 'weeks') {
|
||||
const sel = weekdays.filter((_, i) => recurrenceWeekdays.value[i])
|
||||
if (sel.length) base += ' on ' + sel.join(', ')
|
||||
return recurrenceInterval.value === 1 ? 'Weekly' : `Every ${recurrenceInterval.value} weeks`
|
||||
}
|
||||
base +=
|
||||
' · ' +
|
||||
(recurrenceOccurrences.value === 0
|
||||
? 'no end'
|
||||
: `${recurrenceOccurrences.value} ${recurrenceOccurrences.value === 1 ? 'time' : 'times'}`)
|
||||
return base
|
||||
// months frequency
|
||||
if (recurrenceInterval.value % 12 === 0) {
|
||||
const years = recurrenceInterval.value / 12
|
||||
return years === 1 ? 'Annually' : `Every ${years} years`
|
||||
}
|
||||
return recurrenceInterval.value === 1 ? 'Monthly' : `Every ${recurrenceInterval.value} months`
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -476,15 +428,7 @@ const recurrenceSummary = computed(() => {
|
||||
<span>Repeat</span>
|
||||
</label>
|
||||
<span class="recurrence-summary" v-if="recurrenceEnabled">
|
||||
{{
|
||||
recurrenceInterval === 1
|
||||
? recurrenceFrequency === 'months'
|
||||
? 'Monthly'
|
||||
: recurrenceFrequency === 'years'
|
||||
? 'Annually'
|
||||
: 'Every week'
|
||||
: `Every ${recurrenceInterval} ${recurrenceFrequency}`
|
||||
}}
|
||||
{{ recurrenceSummary }}
|
||||
<template v-if="recurrenceOccurrences > 0">
|
||||
until {{ formattedFinalOccurrence }}</template
|
||||
>
|
||||
@ -504,7 +448,6 @@ const recurrenceSummary = computed(() => {
|
||||
<select v-model="recurrenceFrequency" class="freq-select">
|
||||
<option value="weeks">weeks</option>
|
||||
<option value="months">months</option>
|
||||
<option value="years">years</option>
|
||||
</select>
|
||||
<Numeric
|
||||
class="occ-stepper"
|
||||
@ -797,6 +740,7 @@ const recurrenceSummary = computed(() => {
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--input-border);
|
||||
background: var(--panel-alt);
|
||||
color: var(--ink);
|
||||
border-radius: 0.45rem;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
@ -806,6 +750,7 @@ const recurrenceSummary = computed(() => {
|
||||
outline: none;
|
||||
border-color: var(--input-focus);
|
||||
background: var(--panel-accent);
|
||||
color: var(--ink);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--input-focus),
|
||||
0 0 0 4px rgba(37, 99, 235, 0.15);
|
||||
|
@ -3,16 +3,16 @@
|
||||
<div
|
||||
v-for="span in eventSpans"
|
||||
:key="span.id"
|
||||
class="event-span"
|
||||
:class="[`event-color-${span.colorId}`]"
|
||||
class="event-span"
|
||||
:class="[`event-color-${span.colorId}`]"
|
||||
:style="{
|
||||
gridColumn: `${span.startIdx + 1} / ${span.endIdx + 2}`,
|
||||
gridRow: `${span.row}`
|
||||
gridRow: `${span.row}`,
|
||||
}"
|
||||
@click="handleEventClick(span)"
|
||||
@pointerdown="handleEventPointerDown(span, $event)"
|
||||
@click="handleEventClick(span)"
|
||||
@pointerdown="handleEventPointerDown(span, $event)"
|
||||
>
|
||||
<span class="event-title">{{ span.title }}</span>
|
||||
<span class="event-title">{{ span.title }}</span>
|
||||
<div
|
||||
class="resize-handle left"
|
||||
@pointerdown="handleResizePointerDown(span, 'resize-left', $event)"
|
||||
@ -33,8 +33,8 @@ import { toLocalString, fromLocalString, daysInclusive, addDaysStr } from '@/uti
|
||||
const props = defineProps({
|
||||
week: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['event-click'])
|
||||
@ -60,31 +60,38 @@ function generateRepeatOccurrencesForDate(targetDateStr) {
|
||||
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
||||
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (baseEvent.repeat === 'weekly') {
|
||||
if (baseEvent.repeat === 'weeks') {
|
||||
const repeatWeekdays = baseEvent.repeatWeekdays
|
||||
const targetWeekday = targetDate.getDay()
|
||||
if (!repeatWeekdays[targetWeekday]) continue
|
||||
if (targetDate < baseStartDate) continue
|
||||
const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||
const maxOccurrences =
|
||||
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||
const interval = baseEvent.repeatInterval || 1
|
||||
if (maxOccurrences === 0) continue
|
||||
// Count occurrences from start up to (and including) target
|
||||
let occIdx = 0
|
||||
// Determine the week distance from baseStartDate to targetDate
|
||||
const msPerDay = 24 * 60 * 60 * 1000
|
||||
const daysDiff = Math.floor((targetDate - baseStartDate) / msPerDay)
|
||||
const weeksDiff = Math.floor(daysDiff / 7)
|
||||
if (weeksDiff % interval !== 0) continue
|
||||
// Count occurrences only among valid weeks and selected weekdays
|
||||
const cursor = new Date(baseStartDate)
|
||||
while (cursor < targetDate && occIdx < maxOccurrences) {
|
||||
if (repeatWeekdays[cursor.getDay()]) occIdx++
|
||||
const cDaysDiff = Math.floor((cursor - baseStartDate) / msPerDay)
|
||||
const cWeeksDiff = Math.floor(cDaysDiff / 7)
|
||||
if (cWeeksDiff % interval === 0 && repeatWeekdays[cursor.getDay()]) occIdx++
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
// If target itself is the base start and it's selected, occIdx == 0 => base event (skip)
|
||||
if (cursor.getTime() === targetDate.getTime()) {
|
||||
// We haven't advanced past target, so if its weekday is selected and this is the first occurrence, skip
|
||||
if (occIdx === 0) continue
|
||||
} else {
|
||||
// We advanced past target; if target weekday is selected this is the next occurrence index already counted, so decrement for proper index
|
||||
// Ensure occIdx corresponds to this occurrence (already counted earlier occurrences only)
|
||||
if (targetDate.getTime() === baseStartDate.getTime()) {
|
||||
// skip base occurrence
|
||||
continue
|
||||
}
|
||||
if (occIdx >= maxOccurrences) continue
|
||||
const occStart = new Date(targetDate)
|
||||
const occEnd = new Date(occStart); occEnd.setDate(occStart.getDate() + spanDays)
|
||||
const occEnd = new Date(occStart)
|
||||
occEnd.setDate(occStart.getDate() + spanDays)
|
||||
const occStartStr = toLocalString(occStart)
|
||||
const occEndStr = toLocalString(occEnd)
|
||||
occurrences.push({
|
||||
@ -93,66 +100,46 @@ function generateRepeatOccurrencesForDate(targetDateStr) {
|
||||
startDate: occStartStr,
|
||||
endDate: occEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: occIdx
|
||||
repeatIndex: occIdx,
|
||||
})
|
||||
continue
|
||||
} else {
|
||||
// Handle other repeat types (biweekly, monthly, yearly)
|
||||
// Handle other repeat types (months)
|
||||
let intervalsPassed = 0
|
||||
const timeDiff = targetDate - baseStartDate
|
||||
|
||||
switch (baseEvent.repeat) {
|
||||
case 'biweekly':
|
||||
intervalsPassed = Math.floor(timeDiff / (14 * 24 * 60 * 60 * 1000))
|
||||
break
|
||||
case 'monthly':
|
||||
intervalsPassed = Math.floor((targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
|
||||
(targetDate.getMonth() - baseStartDate.getMonth()))
|
||||
break
|
||||
case 'yearly':
|
||||
intervalsPassed = targetDate.getFullYear() - baseStartDate.getFullYear()
|
||||
break
|
||||
if (baseEvent.repeat === 'months') {
|
||||
intervalsPassed =
|
||||
(targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
|
||||
(targetDate.getMonth() - baseStartDate.getMonth())
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
const interval = baseEvent.repeatInterval || 1
|
||||
if (intervalsPassed < 0 || intervalsPassed % interval !== 0) continue
|
||||
|
||||
// Check a few occurrences around the target date
|
||||
for (let i = Math.max(0, intervalsPassed - 2); i <= intervalsPassed + 2; i++) {
|
||||
const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||
if (i >= maxOccurrences) break
|
||||
|
||||
const currentStart = new Date(baseStartDate)
|
||||
|
||||
switch (baseEvent.repeat) {
|
||||
case 'biweekly':
|
||||
currentStart.setDate(baseStartDate.getDate() + i * 14)
|
||||
break
|
||||
case 'monthly':
|
||||
currentStart.setMonth(baseStartDate.getMonth() + i)
|
||||
break
|
||||
case 'yearly':
|
||||
currentStart.setFullYear(baseStartDate.getFullYear() + i)
|
||||
break
|
||||
}
|
||||
|
||||
const currentEnd = new Date(currentStart)
|
||||
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||
|
||||
// Check if this occurrence intersects with the target date
|
||||
const currentStartStr = toLocalString(currentStart)
|
||||
const currentEndStr = toLocalString(currentEnd)
|
||||
|
||||
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
|
||||
// Skip the original occurrence (i === 0) since it's already in the base events
|
||||
if (i === 0) continue
|
||||
|
||||
occurrences.push({
|
||||
...baseEvent,
|
||||
id: `${baseEvent.id}_repeat_${i}`,
|
||||
startDate: currentStartStr,
|
||||
endDate: currentEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: i
|
||||
})
|
||||
}
|
||||
const maxOccurrences =
|
||||
baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||
if (maxOccurrences === 0) continue
|
||||
const i = intervalsPassed
|
||||
if (i >= maxOccurrences) continue
|
||||
// Skip base occurrence
|
||||
if (i === 0) continue
|
||||
const currentStart = new Date(baseStartDate)
|
||||
currentStart.setMonth(baseStartDate.getMonth() + i)
|
||||
const currentEnd = new Date(currentStart)
|
||||
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||
const currentStartStr = toLocalString(currentStart)
|
||||
const currentEndStr = toLocalString(currentEnd)
|
||||
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
|
||||
occurrences.push({
|
||||
...baseEvent,
|
||||
id: `${baseEvent.id}_repeat_${i}`,
|
||||
startDate: currentStartStr,
|
||||
endDate: currentEndStr,
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: i,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -188,30 +175,36 @@ function handleEventPointerDown(span, event) {
|
||||
const hit = getDateUnderPointer(event.clientX, event.clientY, event.currentTarget)
|
||||
const anchorDate = hit ? hit.date : span.startDate
|
||||
|
||||
startLocalDrag({
|
||||
id: span.id,
|
||||
mode: 'move',
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
anchorDate,
|
||||
startDate: span.startDate,
|
||||
endDate: span.endDate
|
||||
}, event)
|
||||
startLocalDrag(
|
||||
{
|
||||
id: span.id,
|
||||
mode: 'move',
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
anchorDate,
|
||||
startDate: span.startDate,
|
||||
endDate: span.endDate,
|
||||
},
|
||||
event,
|
||||
)
|
||||
}
|
||||
|
||||
// Handle resize handle pointer down
|
||||
function handleResizePointerDown(span, mode, event) {
|
||||
event.stopPropagation()
|
||||
// Start drag from the current edge; anchorDate not needed for resize
|
||||
startLocalDrag({
|
||||
id: span.id,
|
||||
mode,
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
anchorDate: null,
|
||||
startDate: span.startDate,
|
||||
endDate: span.endDate
|
||||
}, event)
|
||||
startLocalDrag(
|
||||
{
|
||||
id: span.id,
|
||||
mode,
|
||||
pointerStartX: event.clientX,
|
||||
pointerStartY: event.clientY,
|
||||
anchorDate: null,
|
||||
startDate: span.startDate,
|
||||
endDate: span.endDate,
|
||||
},
|
||||
event,
|
||||
)
|
||||
}
|
||||
|
||||
// Get date under pointer coordinates
|
||||
@ -228,7 +221,7 @@ function getDateUnderPointer(clientX, clientY, targetEl) {
|
||||
}
|
||||
|
||||
// Restore pointer events for hidden elements
|
||||
hiddenElements.forEach(el => el.style.pointerEvents = 'auto')
|
||||
hiddenElements.forEach((el) => (el.style.pointerEvents = 'auto'))
|
||||
|
||||
if (element) {
|
||||
// Look for a day cell with data-date attribute
|
||||
@ -316,7 +309,7 @@ function startLocalDrag(init, evt) {
|
||||
...init,
|
||||
anchorOffset,
|
||||
originSpanDays: spanDays,
|
||||
eventMoved: false
|
||||
eventMoved: false,
|
||||
}
|
||||
|
||||
// Capture pointer events globally
|
||||
@ -378,7 +371,9 @@ function onDragPointerUp(e) {
|
||||
|
||||
if (moved) {
|
||||
justDragged.value = true
|
||||
setTimeout(() => { justDragged.value = false }, 120)
|
||||
setTimeout(() => {
|
||||
justDragged.value = false
|
||||
}, 120)
|
||||
}
|
||||
}
|
||||
|
||||
@ -407,20 +402,40 @@ function normalizeDateOrder(aStr, bStr) {
|
||||
}
|
||||
|
||||
function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
const ev = store.getEventById(st.id)
|
||||
if (!ev) return
|
||||
if (ev.isRepeatOccurrence) {
|
||||
const idParts = String(st.id).split('_repeat_')
|
||||
const baseId = idParts[0]
|
||||
const repeatParts = idParts[1].split('_')
|
||||
const repeatIndex = parseInt(repeatParts[0], 10) || 0
|
||||
const grabbedWeekday = repeatParts.length > 1 ? parseInt(repeatParts[1], 10) : null
|
||||
let ev = store.getEventById(st.id)
|
||||
let isRepeatOccurrence = false
|
||||
let baseId = st.id
|
||||
let repeatIndex = 0
|
||||
let grabbedWeekday = null
|
||||
|
||||
// If not found (repeat occurrences aren't stored) parse synthetic id
|
||||
if (!ev && typeof st.id === 'string' && st.id.includes('_repeat_')) {
|
||||
const [bid, suffix] = st.id.split('_repeat_')
|
||||
baseId = bid
|
||||
ev = store.getEventById(baseId)
|
||||
if (ev) {
|
||||
const parts = suffix.split('_')
|
||||
repeatIndex = parseInt(parts[0], 10) || 0
|
||||
grabbedWeekday = parts.length > 1 ? parseInt(parts[1], 10) : null
|
||||
isRepeatOccurrence = repeatIndex >= 0
|
||||
}
|
||||
}
|
||||
|
||||
if (!ev) return
|
||||
|
||||
const mode = st.mode === 'resize-left' || st.mode === 'resize-right' ? st.mode : 'move'
|
||||
if (isRepeatOccurrence) {
|
||||
if (repeatIndex === 0) {
|
||||
store.setEventRange(baseId, startDate, endDate)
|
||||
store.setEventRange(baseId, startDate, endDate, { mode })
|
||||
} else {
|
||||
if (!st.splitNewBaseId) {
|
||||
const newId = store.splitRepeatSeries(baseId, repeatIndex, startDate, endDate, grabbedWeekday)
|
||||
const newId = store.splitRepeatSeries(
|
||||
baseId,
|
||||
repeatIndex,
|
||||
startDate,
|
||||
endDate,
|
||||
grabbedWeekday,
|
||||
)
|
||||
if (newId) {
|
||||
st.splitNewBaseId = newId
|
||||
st.id = newId
|
||||
@ -428,11 +443,11 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
||||
st.endDate = endDate
|
||||
}
|
||||
} else {
|
||||
store.setEventRange(st.splitNewBaseId, startDate, endDate)
|
||||
store.setEventRange(st.splitNewBaseId, startDate, endDate, { mode })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.setEventRange(st.id, startDate, endDate)
|
||||
store.setEventRange(st.id, startDate, endDate, { mode })
|
||||
}
|
||||
}
|
||||
|
||||
@ -444,12 +459,12 @@ const eventSpans = computed(() => {
|
||||
// Collect events from all days in this week, including repeat occurrences
|
||||
props.week.days.forEach((day, dayIndex) => {
|
||||
// Get base events for this day
|
||||
day.events.forEach(event => {
|
||||
day.events.forEach((event) => {
|
||||
if (!weekEvents.has(event.id)) {
|
||||
weekEvents.set(event.id, {
|
||||
...event,
|
||||
startIdx: dayIndex,
|
||||
endIdx: dayIndex
|
||||
endIdx: dayIndex,
|
||||
})
|
||||
} else {
|
||||
const existing = weekEvents.get(event.id)
|
||||
@ -459,12 +474,12 @@ const eventSpans = computed(() => {
|
||||
|
||||
// Generate repeat occurrences for this day
|
||||
const repeatOccurrences = generateRepeatOccurrencesForDate(day.date)
|
||||
repeatOccurrences.forEach(event => {
|
||||
repeatOccurrences.forEach((event) => {
|
||||
if (!weekEvents.has(event.id)) {
|
||||
weekEvents.set(event.id, {
|
||||
...event,
|
||||
startIdx: dayIndex,
|
||||
endIdx: dayIndex
|
||||
endIdx: dayIndex,
|
||||
})
|
||||
} else {
|
||||
const existing = weekEvents.get(event.id)
|
||||
@ -495,7 +510,7 @@ const eventSpans = computed(() => {
|
||||
|
||||
// Assign rows to avoid overlaps
|
||||
const rowsLastEnd = []
|
||||
eventArray.forEach(event => {
|
||||
eventArray.forEach((event) => {
|
||||
let placedRow = 0
|
||||
while (placedRow < rowsLastEnd.length && !(event.startIdx > rowsLastEnd[placedRow])) {
|
||||
placedRow++
|
||||
|
@ -13,14 +13,14 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
config: {
|
||||
select_days: 1000,
|
||||
min_year: MIN_YEAR,
|
||||
max_year: MAX_YEAR
|
||||
}
|
||||
max_year: MAX_YEAR,
|
||||
},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// Basic configuration getters
|
||||
minYear: () => MIN_YEAR,
|
||||
maxYear: () => MAX_YEAR
|
||||
maxYear: () => MAX_YEAR,
|
||||
},
|
||||
|
||||
actions: {
|
||||
@ -49,13 +49,20 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
title: eventData.title,
|
||||
startDate: eventData.startDate,
|
||||
endDate: eventData.endDate,
|
||||
colorId: eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
|
||||
startTime: singleDay ? (eventData.startTime || '09:00') : null,
|
||||
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null,
|
||||
repeat: eventData.repeat || 'none',
|
||||
colorId:
|
||||
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
|
||||
startTime: singleDay ? eventData.startTime || '09:00' : null,
|
||||
durationMinutes: singleDay ? eventData.durationMinutes || 60 : null,
|
||||
repeat:
|
||||
(eventData.repeat === 'weekly'
|
||||
? 'weeks'
|
||||
: eventData.repeat === 'monthly'
|
||||
? 'months'
|
||||
: eventData.repeat) || 'none',
|
||||
repeatInterval: eventData.repeatInterval || 1,
|
||||
repeatCount: eventData.repeatCount || 'unlimited',
|
||||
repeatWeekdays: eventData.repeatWeekdays,
|
||||
isRepeating: (eventData.repeat && eventData.repeat !== 'none')
|
||||
isRepeating: eventData.repeat && eventData.repeat !== 'none',
|
||||
}
|
||||
|
||||
const startDate = new Date(fromLocalString(event.startDate))
|
||||
@ -68,12 +75,13 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
}
|
||||
this.events.get(dateStr).push({ ...event, isSpanning: startDate < endDate })
|
||||
}
|
||||
// No physical expansion; repeats are virtual
|
||||
return event.id
|
||||
},
|
||||
|
||||
getEventById(id) {
|
||||
for (const [, list] of this.events) {
|
||||
const found = list.find(e => e.id === id)
|
||||
const found = list.find((e) => e.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
@ -110,7 +118,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
deleteEvent(eventId) {
|
||||
const datesToCleanup = []
|
||||
for (const [dateStr, eventList] of this.events) {
|
||||
const eventIndex = eventList.findIndex(event => event.id === eventId)
|
||||
const eventIndex = eventList.findIndex((event) => event.id === eventId)
|
||||
if (eventIndex !== -1) {
|
||||
eventList.splice(eventIndex, 1)
|
||||
if (eventList.length === 0) {
|
||||
@ -118,17 +126,21 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
}
|
||||
}
|
||||
}
|
||||
datesToCleanup.forEach(dateStr => this.events.delete(dateStr))
|
||||
datesToCleanup.forEach((dateStr) => this.events.delete(dateStr))
|
||||
},
|
||||
|
||||
deleteSingleOccurrence(ctx) {
|
||||
const { baseId, occurrenceIndex, weekday } = ctx
|
||||
const { baseId, occurrenceIndex } = ctx
|
||||
const base = this.getEventById(baseId)
|
||||
if (!base || base.repeat !== 'weekly') return
|
||||
if (!base || base.repeat !== 'weeks') return
|
||||
// Strategy: clone pattern into two: reduce base repeatCount to exclude this occurrence; create new series for remainder excluding this one
|
||||
// Simpler: convert base to explicit exception by creating a one-off non-repeating event? For now: create exception by inserting a blocking dummy? Instead just split by shifting repeatCount logic: decrement repeatCount and create a new series starting after this single occurrence.
|
||||
// Implementation (approx): terminate at occurrenceIndex; create a new series starting next occurrence with remaining occurrences.
|
||||
const remaining = base.repeatCount === 'unlimited' ? 'unlimited' : String(Math.max(0, parseInt(base.repeatCount,10) - (occurrenceIndex+1)))
|
||||
const remaining =
|
||||
base.repeatCount === 'unlimited'
|
||||
? 'unlimited'
|
||||
: String(Math.max(0, parseInt(base.repeatCount, 10) - (occurrenceIndex + 1)))
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
if (remaining === '0') return
|
||||
// Find date of next occurrence
|
||||
@ -145,9 +157,9 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
startDate: nextStartStr,
|
||||
endDate: nextStartStr,
|
||||
colorId: base.colorId,
|
||||
repeat: 'weekly',
|
||||
repeat: 'weeks',
|
||||
repeatCount: remaining,
|
||||
repeatWeekdays: base.repeatWeekdays
|
||||
repeatWeekdays: base.repeatWeekdays,
|
||||
})
|
||||
},
|
||||
|
||||
@ -164,21 +176,19 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000))
|
||||
let newStart = null
|
||||
|
||||
if (base.repeat === 'weekly' && base.repeatWeekdays) {
|
||||
if (base.repeat === 'weeks' && base.repeatWeekdays) {
|
||||
const probe = new Date(oldStart)
|
||||
for (let i = 0; i < 14; i++) { // search ahead up to 2 weeks
|
||||
for (let i = 0; i < 14; i++) {
|
||||
// search ahead up to 2 weeks
|
||||
probe.setDate(probe.getDate() + 1)
|
||||
if (base.repeatWeekdays[probe.getDay()]) { newStart = new Date(probe); break }
|
||||
if (base.repeatWeekdays[probe.getDay()]) {
|
||||
newStart = new Date(probe)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (base.repeat === 'biweekly') {
|
||||
newStart = new Date(oldStart)
|
||||
newStart.setDate(newStart.getDate() + 14)
|
||||
} else if (base.repeat === 'monthly') {
|
||||
} else if (base.repeat === 'months') {
|
||||
newStart = new Date(oldStart)
|
||||
newStart.setMonth(newStart.getMonth() + 1)
|
||||
} else if (base.repeat === 'yearly') {
|
||||
newStart = new Date(oldStart)
|
||||
newStart.setFullYear(newStart.getFullYear() + 1)
|
||||
} else {
|
||||
// Unknown pattern: delete entire series
|
||||
this.deleteEvent(baseId)
|
||||
@ -207,63 +217,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
newEnd.setDate(newEnd.getDate() + spanDays)
|
||||
base.startDate = toLocalString(newStart)
|
||||
base.endDate = toLocalString(newEnd)
|
||||
// Reindex across map
|
||||
this._removeEventFromAllDatesById(baseId)
|
||||
this._addEventToDateRangeWithId(baseId, base, base.startDate, base.endDate)
|
||||
},
|
||||
|
||||
updateEvent(eventId, updates) {
|
||||
// Remove event from current dates
|
||||
for (const [dateStr, eventList] of this.events) {
|
||||
const index = eventList.findIndex(e => e.id === eventId)
|
||||
if (index !== -1) {
|
||||
const event = eventList[index]
|
||||
eventList.splice(index, 1)
|
||||
if (eventList.length === 0) {
|
||||
this.events.delete(dateStr)
|
||||
}
|
||||
|
||||
// Create updated event and add to new date range
|
||||
const updatedEvent = { ...event, ...updates }
|
||||
this._addEventToDateRange(updatedEvent)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Minimal public API for component-driven drag
|
||||
setEventRange(eventId, startDate, endDate) {
|
||||
const snapshot = this._snapshotBaseEvent(eventId)
|
||||
if (!snapshot) return
|
||||
|
||||
// Calculate rotated weekdays for weekly repeats
|
||||
if (snapshot.repeat === 'weekly' && snapshot.repeatWeekdays) {
|
||||
const originalStartDate = new Date(fromLocalString(snapshot.startDate))
|
||||
const newStartDate = new Date(fromLocalString(startDate))
|
||||
const dayShift = newStartDate.getDay() - originalStartDate.getDay()
|
||||
|
||||
if (dayShift !== 0) {
|
||||
const rotatedWeekdays = [false, false, false, false, false, false, false]
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (snapshot.repeatWeekdays[i]) {
|
||||
let newDay = (i + dayShift) % 7
|
||||
if (newDay < 0) newDay += 7
|
||||
rotatedWeekdays[newDay] = true
|
||||
}
|
||||
}
|
||||
snapshot.repeatWeekdays = rotatedWeekdays
|
||||
}
|
||||
}
|
||||
|
||||
this._removeEventFromAllDatesById(eventId)
|
||||
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
|
||||
},
|
||||
|
||||
splitRepeatSeries(baseId, index, startDate, endDate, grabbedWeekday = null) {
|
||||
const base = this.getEventById(baseId)
|
||||
if (!base) return null
|
||||
|
||||
// old occurrence expansion removed (series handled differently now)
|
||||
const originalRepeatCount = base.repeatCount
|
||||
// Always cap original series at the split occurrence index (occurrences 0..index-1)
|
||||
// Keep its weekday pattern unchanged.
|
||||
@ -284,12 +238,12 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
|
||||
// Handle weekdays for weekly repeats
|
||||
let newRepeatWeekdays = base.repeatWeekdays
|
||||
if (base.repeat === 'weekly' && base.repeatWeekdays) {
|
||||
if (base.repeat === 'weeks' && base.repeatWeekdays) {
|
||||
const newStartDate = new Date(fromLocalString(startDate))
|
||||
let dayShift = 0
|
||||
if (grabbedWeekday != null) {
|
||||
// Rotate so that the grabbed weekday maps to the new start weekday
|
||||
dayShift = newStartDate.getDay() - grabbedWeekday
|
||||
dayShift = newStartDate.getDay() - grabbedWeekday
|
||||
} else {
|
||||
// Fallback: rotate by difference between new and original start weekday
|
||||
const originalStartDate = new Date(fromLocalString(base.startDate))
|
||||
@ -315,16 +269,15 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
colorId: base.colorId,
|
||||
repeat: base.repeat,
|
||||
repeatCount: newRepeatCount,
|
||||
repeatWeekdays: newRepeatWeekdays
|
||||
repeatWeekdays: newRepeatWeekdays,
|
||||
})
|
||||
return newId
|
||||
},
|
||||
|
||||
|
||||
_snapshotBaseEvent(eventId) {
|
||||
// Return a shallow snapshot of any instance for metadata
|
||||
for (const [, eventList] of this.events) {
|
||||
const e = eventList.find(x => x.id === eventId)
|
||||
const e = eventList.find((x) => x.id === eventId)
|
||||
if (e) return { ...e }
|
||||
}
|
||||
return null
|
||||
@ -350,7 +303,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
id: eventId,
|
||||
startDate,
|
||||
endDate,
|
||||
isSpanning: multi
|
||||
isSpanning: multi,
|
||||
}
|
||||
// Normalize single-day time fields
|
||||
if (!multi) {
|
||||
@ -369,6 +322,98 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
}
|
||||
},
|
||||
|
||||
// expandRepeats removed: no physical occurrence expansion
|
||||
|
||||
// Adjust start/end range of a base event (non-generated) and reindex occurrences
|
||||
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
|
||||
const snapshot = this._findEventInAnyList(eventId)
|
||||
if (!snapshot) return
|
||||
// Calculate current duration in days (inclusive)
|
||||
const prevStart = new Date(fromLocalString(snapshot.startDate))
|
||||
const prevEnd = new Date(fromLocalString(snapshot.endDate))
|
||||
const prevDurationDays = Math.max(
|
||||
0,
|
||||
Math.round((prevEnd - prevStart) / (24 * 60 * 60 * 1000)),
|
||||
)
|
||||
|
||||
const newStart = new Date(fromLocalString(newStartStr))
|
||||
const newEnd = new Date(fromLocalString(newEndStr))
|
||||
const proposedDurationDays = Math.max(
|
||||
0,
|
||||
Math.round((newEnd - newStart) / (24 * 60 * 60 * 1000)),
|
||||
)
|
||||
|
||||
let finalDurationDays = prevDurationDays
|
||||
if (mode === 'resize-left' || mode === 'resize-right') {
|
||||
finalDurationDays = proposedDurationDays
|
||||
}
|
||||
|
||||
snapshot.startDate = newStartStr
|
||||
snapshot.endDate = toLocalString(
|
||||
new Date(
|
||||
new Date(fromLocalString(newStartStr)).setDate(
|
||||
new Date(fromLocalString(newStartStr)).getDate() + finalDurationDays,
|
||||
),
|
||||
),
|
||||
)
|
||||
// Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift
|
||||
if (
|
||||
mode === 'move' &&
|
||||
snapshot.isRepeating &&
|
||||
snapshot.repeat === 'weeks' &&
|
||||
Array.isArray(snapshot.repeatWeekdays)
|
||||
) {
|
||||
const oldDow = prevStart.getDay()
|
||||
const newDow = newStart.getDay()
|
||||
const shift = newDow - oldDow
|
||||
if (shift !== 0) {
|
||||
const rotated = [false, false, false, false, false, false, false]
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (snapshot.repeatWeekdays[i]) {
|
||||
let ni = (i + shift) % 7
|
||||
if (ni < 0) ni += 7
|
||||
rotated[ni] = true
|
||||
}
|
||||
}
|
||||
snapshot.repeatWeekdays = rotated
|
||||
}
|
||||
}
|
||||
// Reindex
|
||||
this._removeEventFromAllDatesById(eventId)
|
||||
this._addEventToDateRangeWithId(eventId, snapshot, snapshot.startDate, snapshot.endDate)
|
||||
// no expansion
|
||||
},
|
||||
|
||||
// Split a repeating series at a given occurrence index; returns new series id
|
||||
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) {
|
||||
const base = this._findEventInAnyList(baseId)
|
||||
if (!base || !base.isRepeating) return null
|
||||
// Capture original repeatCount BEFORE truncation
|
||||
const originalCountRaw = base.repeatCount
|
||||
// Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1)
|
||||
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||
// Compute new series repeatCount (remaining occurrences starting at occurrenceIndex)
|
||||
let newSeriesCount = 'unlimited'
|
||||
if (originalCountRaw !== 'unlimited') {
|
||||
const originalNum = parseInt(originalCountRaw, 10)
|
||||
if (!isNaN(originalNum)) {
|
||||
const remaining = originalNum - occurrenceIndex
|
||||
newSeriesCount = String(Math.max(1, remaining))
|
||||
}
|
||||
}
|
||||
const newId = this.createEvent({
|
||||
title: base.title,
|
||||
startDate: newStartStr,
|
||||
endDate: newEndStr,
|
||||
colorId: base.colorId,
|
||||
repeat: base.repeat,
|
||||
repeatInterval: base.repeatInterval,
|
||||
repeatCount: newSeriesCount,
|
||||
repeatWeekdays: base.repeatWeekdays,
|
||||
})
|
||||
return newId
|
||||
},
|
||||
|
||||
_reindexBaseEvent(eventId, snapshot, startDate, endDate) {
|
||||
if (!snapshot) return
|
||||
this._removeEventFromAllDatesById(eventId)
|
||||
@ -393,7 +438,7 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
|
||||
_findEventInAnyList(eventId) {
|
||||
for (const [, eventList] of this.events) {
|
||||
const found = eventList.find(e => e.id === eventId)
|
||||
const found = eventList.find((e) => e.id === eventId)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
@ -414,62 +459,6 @@ export const useCalendarStore = defineStore('calendar', {
|
||||
}
|
||||
},
|
||||
|
||||
getEventById(id) {
|
||||
// Check for base events first
|
||||
for (const [, list] of this.events) {
|
||||
const found = list.find(e => e.id === id)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
// Check if it's a repeat occurrence ID
|
||||
if (typeof id === 'string' && id.includes('_repeat_')) {
|
||||
const parts = id.split('_repeat_')
|
||||
const baseId = parts[0]
|
||||
const repeatIndex = parseInt(parts[1], 10)
|
||||
|
||||
if (isNaN(repeatIndex)) return null
|
||||
|
||||
const baseEvent = this.getEventById(baseId)
|
||||
if (baseEvent && baseEvent.isRepeating) {
|
||||
// Generate the specific occurrence
|
||||
const baseStartDate = new Date(fromLocalString(baseEvent.startDate))
|
||||
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
||||
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
||||
|
||||
const currentStart = new Date(baseStartDate)
|
||||
switch (baseEvent.repeat) {
|
||||
case 'daily':
|
||||
currentStart.setDate(baseStartDate.getDate() + repeatIndex)
|
||||
break
|
||||
case 'weekly':
|
||||
currentStart.setDate(baseStartDate.getDate() + repeatIndex * 7)
|
||||
break
|
||||
case 'biweekly':
|
||||
currentStart.setDate(baseStartDate.getDate() + repeatIndex * 14)
|
||||
break
|
||||
case 'monthly':
|
||||
currentStart.setMonth(baseStartDate.getMonth() + repeatIndex)
|
||||
break
|
||||
case 'yearly':
|
||||
currentStart.setFullYear(baseStartDate.getFullYear() + repeatIndex)
|
||||
break
|
||||
}
|
||||
|
||||
const currentEnd = new Date(currentStart)
|
||||
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||
|
||||
return {
|
||||
...baseEvent,
|
||||
id: id,
|
||||
startDate: toLocalString(currentStart),
|
||||
endDate: toLocalString(currentEnd),
|
||||
isRepeatOccurrence: true,
|
||||
repeatIndex: repeatIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
// NOTE: legacy dynamic getEventById for synthetic occurrences removed.
|
||||
},
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user