vue #1
@ -308,9 +308,9 @@ const handleDayTouchEnd = (dateStr) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEventClick = (eventId) => {
|
const handleEventClick = (eventInstanceId) => {
|
||||||
if (eventDialog.value) {
|
if (eventDialog.value) {
|
||||||
eventDialog.value.openEditDialog(eventId)
|
eventDialog.value.openEditDialog(eventInstanceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useCalendarStore } from '@/stores/CalendarStore'
|
import { useCalendarStore } from '@/stores/CalendarStore'
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selection: { type: Object, default: () => ({ start: null, end: null }) }
|
selection: { type: Object, default: () => ({ start: null, end: null }) }
|
||||||
@ -12,11 +12,14 @@ const calendarStore = useCalendarStore()
|
|||||||
|
|
||||||
const showDialog = ref(false)
|
const showDialog = ref(false)
|
||||||
const dialogMode = ref('create') // 'create' or 'edit'
|
const dialogMode = ref('create') // 'create' or 'edit'
|
||||||
const editingEventId = ref(null)
|
const editingEventId = ref(null) // base event id if repeating occurrence clicked
|
||||||
|
const occurrenceContext = ref(null) // { baseId, occurrenceIndex, weekday, occurrenceDate }
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const repeat = ref('none')
|
const repeat = ref('none')
|
||||||
|
const repeatWeekdays = ref([false, false, false, false, false, false, false]) // Sun-Sat
|
||||||
const colorId = ref(0)
|
const colorId = ref(0)
|
||||||
const eventSaved = ref(false)
|
const eventSaved = ref(false)
|
||||||
|
const titleInput = ref(null)
|
||||||
|
|
||||||
const selectedColor = computed({
|
const selectedColor = computed({
|
||||||
get: () => colorId.value,
|
get: () => colorId.value,
|
||||||
@ -30,9 +33,11 @@ const selectedColor = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function openCreateDialog() {
|
function openCreateDialog() {
|
||||||
|
occurrenceContext.value = null
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
title.value = ''
|
title.value = ''
|
||||||
repeat.value = 'none'
|
repeat.value = 'none'
|
||||||
|
repeatWeekdays.value = [false, false, false, false, false, false, false]
|
||||||
colorId.value = calendarStore.selectEventColorId(props.selection.start, props.selection.end)
|
colorId.value = calendarStore.selectEventColorId(props.selection.start, props.selection.end)
|
||||||
eventSaved.value = false
|
eventSaved.value = false
|
||||||
|
|
||||||
@ -42,22 +47,71 @@ function openCreateDialog() {
|
|||||||
startDate: props.selection.start,
|
startDate: props.selection.start,
|
||||||
endDate: props.selection.end,
|
endDate: props.selection.end,
|
||||||
colorId: colorId.value,
|
colorId: colorId.value,
|
||||||
repeat: repeat.value
|
repeat: repeat.value,
|
||||||
|
repeatWeekdays: repeatWeekdays.value
|
||||||
})
|
})
|
||||||
|
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
|
|
||||||
|
// Focus and select text after dialog is shown
|
||||||
|
nextTick(() => {
|
||||||
|
if (titleInput.value) {
|
||||||
|
titleInput.value.focus()
|
||||||
|
if (title.value) {
|
||||||
|
titleInput.value.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditDialog(eventId) {
|
function openEditDialog(eventInstanceId) {
|
||||||
const event = calendarStore.getEventById(eventId)
|
occurrenceContext.value = null
|
||||||
|
let baseId = eventInstanceId
|
||||||
|
let occurrenceIndex = 0
|
||||||
|
let weekday = null
|
||||||
|
let occurrenceDate = null
|
||||||
|
if (typeof eventInstanceId === 'string' && eventInstanceId.includes('_repeat_')) {
|
||||||
|
const [bid, suffix] = eventInstanceId.split('_repeat_')
|
||||||
|
baseId = bid
|
||||||
|
const parts = suffix.split('_')
|
||||||
|
occurrenceIndex = parseInt(parts[0], 10) || 0
|
||||||
|
if (parts.length > 1) weekday = parseInt(parts[1], 10)
|
||||||
|
}
|
||||||
|
const event = calendarStore.getEventById(baseId)
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
// Derive occurrence date if weekly occurrence
|
||||||
|
if (weekday != null) {
|
||||||
|
// Recompute occurrence date: iterate days accumulating selected weekdays
|
||||||
|
const repeatWeekdaysLocal = event.repeatWeekdays
|
||||||
|
let idx = 0
|
||||||
|
let cur = new Date(event.startDate + 'T00:00:00')
|
||||||
|
while (idx < occurrenceIndex && idx < 10000) { // safety bound
|
||||||
|
cur.setDate(cur.getDate() + 1)
|
||||||
|
if (repeatWeekdaysLocal[cur.getDay()]) idx++
|
||||||
|
}
|
||||||
|
occurrenceDate = cur
|
||||||
|
}
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
editingEventId.value = eventId
|
editingEventId.value = baseId
|
||||||
title.value = event.title
|
title.value = event.title
|
||||||
repeat.value = event.repeat
|
repeat.value = event.repeat
|
||||||
|
repeatWeekdays.value = event.repeatWeekdays
|
||||||
colorId.value = event.colorId
|
colorId.value = event.colorId
|
||||||
eventSaved.value = false
|
eventSaved.value = false
|
||||||
|
if (event.isRepeating && occurrenceIndex >= 0 && weekday != null) {
|
||||||
|
occurrenceContext.value = { baseId, occurrenceIndex, weekday, occurrenceDate }
|
||||||
|
}
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
|
|
||||||
|
// Focus and select text after dialog is shown
|
||||||
|
nextTick(() => {
|
||||||
|
if (titleInput.value) {
|
||||||
|
titleInput.value.focus()
|
||||||
|
if (title.value) {
|
||||||
|
titleInput.value.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
@ -79,6 +133,7 @@ function updateEventInStore() {
|
|||||||
event.title = title.value
|
event.title = title.value
|
||||||
event.colorId = colorId.value
|
event.colorId = colorId.value
|
||||||
event.repeat = repeat.value
|
event.repeat = repeat.value
|
||||||
|
event.repeatWeekdays = [...repeatWeekdays.value]
|
||||||
// Update repeat status
|
// Update repeat status
|
||||||
event.isRepeating = (repeat.value && repeat.value !== 'none')
|
event.isRepeating = (repeat.value && repeat.value !== 'none')
|
||||||
}
|
}
|
||||||
@ -100,13 +155,27 @@ function saveEvent() {
|
|||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEvent() {
|
function deleteEventAll() {
|
||||||
if (editingEventId.value) {
|
if (editingEventId.value) calendarStore.deleteEvent(editingEventId.value)
|
||||||
calendarStore.deleteEvent(editingEventId.value)
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEventOne() {
|
||||||
|
if (occurrenceContext.value) {
|
||||||
|
calendarStore.deleteSingleOccurrence(occurrenceContext.value)
|
||||||
|
} else if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||||
|
// Delete the first occurrence of the repeating series
|
||||||
|
calendarStore.deleteFirstOccurrence(editingEventId.value)
|
||||||
}
|
}
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deleteEventFrom() {
|
||||||
|
if (!occurrenceContext.value) return
|
||||||
|
calendarStore.deleteFromOccurrence(occurrenceContext.value)
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
// Watch for title changes and update the event immediately
|
// Watch for title changes and update the event immediately
|
||||||
watch(title, (newTitle) => {
|
watch(title, (newTitle) => {
|
||||||
if (editingEventId.value && showDialog.value) {
|
if (editingEventId.value && showDialog.value) {
|
||||||
@ -117,10 +186,25 @@ watch(title, (newTitle) => {
|
|||||||
// Watch for repeat changes and update the event immediately
|
// Watch for repeat changes and update the event immediately
|
||||||
watch(repeat, (newRepeat) => {
|
watch(repeat, (newRepeat) => {
|
||||||
if (editingEventId.value && showDialog.value) {
|
if (editingEventId.value && showDialog.value) {
|
||||||
|
// If switching to weekly, default to the current weekday
|
||||||
|
if (newRepeat === 'weekly' && !repeatWeekdays.value.some(Boolean)) {
|
||||||
|
const event = calendarStore.getEventById(editingEventId.value)
|
||||||
|
if (event) {
|
||||||
|
const startDate = new Date(event.startDate + 'T00:00:00')
|
||||||
|
repeatWeekdays.value[startDate.getDay()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
updateEventInStore()
|
updateEventInStore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Watch for repeatWeekdays changes and update the event immediately
|
||||||
|
watch(repeatWeekdays, () => {
|
||||||
|
if (editingEventId.value && showDialog.value && repeat.value === 'weekly') {
|
||||||
|
updateEventInStore()
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// Handle Esc key to close dialog
|
// Handle Esc key to close dialog
|
||||||
function handleKeydown(event) {
|
function handleKeydown(event) {
|
||||||
if (event.key === 'Escape' && showDialog.value) {
|
if (event.key === 'Escape' && showDialog.value) {
|
||||||
@ -140,6 +224,27 @@ defineExpose({
|
|||||||
openCreateDialog,
|
openCreateDialog,
|
||||||
openEditDialog
|
openEditDialog
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Computed helpers for delete UI
|
||||||
|
const isRepeatingEdit = computed(() => dialogMode.value === 'edit' && repeat.value !== 'none')
|
||||||
|
const showDeleteVariants = computed(() => isRepeatingEdit.value && occurrenceContext.value)
|
||||||
|
const isRepeatingBaseEdit = computed(() => isRepeatingEdit.value && !occurrenceContext.value)
|
||||||
|
const formattedOccurrenceShort = computed(() => {
|
||||||
|
if (occurrenceContext.value?.occurrenceDate) {
|
||||||
|
try {
|
||||||
|
return occurrenceContext.value.occurrenceDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
if (isRepeatingBaseEdit.value && editingEventId.value) {
|
||||||
|
const ev = calendarStore.getEventById(editingEventId.value)
|
||||||
|
if (ev?.startDate) {
|
||||||
|
try {
|
||||||
|
return new Date(ev.startDate + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -152,18 +257,7 @@ defineExpose({
|
|||||||
<div class="ec-body">
|
<div class="ec-body">
|
||||||
<label class="ec-field">
|
<label class="ec-field">
|
||||||
<span>Title</span>
|
<span>Title</span>
|
||||||
<input type="text" v-model="title" autocomplete="off" />
|
<input type="text" v-model="title" autocomplete="off" ref="titleInput" />
|
||||||
</label>
|
|
||||||
<label class="ec-field">
|
|
||||||
<span>Repeat</span>
|
|
||||||
<select v-model="repeat">
|
|
||||||
<option value="none">No repeat</option>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
<option value="weekly">Weekly</option>
|
|
||||||
<option value="biweekly">Every 2 weeks</option>
|
|
||||||
<option value="monthly">Monthly</option>
|
|
||||||
<option value="yearly">Yearly</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="ec-color-swatches">
|
<div class="ec-color-swatches">
|
||||||
<label v-for="i in 8" :key="i-1" class="swatch-label">
|
<label v-for="i in 8" :key="i-1" class="swatch-label">
|
||||||
@ -177,16 +271,54 @@ defineExpose({
|
|||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="ec-field">
|
||||||
|
<span>Repeat</span>
|
||||||
|
<select v-model="repeat">
|
||||||
|
<option value="none">No repeat</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="biweekly">Every 2 weeks</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
<option value="yearly">Yearly</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div v-if="repeat === 'weekly'" class="ec-weekday-selector">
|
||||||
|
<span class="ec-field-label">Repeat on:</span>
|
||||||
|
<div class="ec-weekdays">
|
||||||
|
<label v-for="(day, index) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
|
||||||
|
:key="index"
|
||||||
|
class="ec-weekday-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
v-model="repeatWeekdays[index]"
|
||||||
|
class="ec-weekday-checkbox"
|
||||||
|
>
|
||||||
|
<span class="ec-weekday-text">{{ day }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer class="ec-footer">
|
<footer class="ec-footer">
|
||||||
<!-- Create mode: Delete and Save buttons -->
|
|
||||||
<template v-if="dialogMode === 'create'">
|
<template v-if="dialogMode === 'create'">
|
||||||
<button type="button" class="ec-btn delete-btn" @click="deleteEvent">Delete</button>
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
|
||||||
<button type="submit" class="ec-btn save-btn">Save</button>
|
<button type="submit" class="ec-btn save-btn">Save</button>
|
||||||
</template>
|
</template>
|
||||||
<!-- Edit mode: Delete and Close buttons -->
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button type="button" class="ec-btn delete-btn" @click="deleteEvent">Delete</button>
|
<template v-if="showDeleteVariants">
|
||||||
|
<div class="ec-delete-group">
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">Delete {{ formattedOccurrenceShort }}</button>
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventFrom">Rest</button>
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isRepeatingBaseEdit">
|
||||||
|
<div class="ec-delete-group">
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventOne">Delete {{ formattedOccurrenceShort }}</button>
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">All</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button type="button" class="ec-btn delete-btn" @click="deleteEventAll">Delete</button>
|
||||||
|
</template>
|
||||||
<button type="button" class="ec-btn close-btn" @click="closeDialog">Close</button>
|
<button type="button" class="ec-btn close-btn" @click="closeDialog">Close</button>
|
||||||
</template>
|
</template>
|
||||||
</footer>
|
</footer>
|
||||||
@ -339,4 +471,45 @@ defineExpose({
|
|||||||
.ec-btn.delete-btn:hover {
|
.ec-btn.delete-btn:hover {
|
||||||
background: hsl(0, 70%, 45%);
|
background: hsl(0, 70%, 45%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ec-weekday-selector {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-field-label {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-weekdays {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-weekday-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-weekday-label:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-weekday-checkbox {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-weekday-text {
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -60,73 +60,99 @@ function generateRepeatOccurrencesForDate(targetDateStr) {
|
|||||||
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
|
||||||
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
|
||||||
|
|
||||||
// Calculate how many intervals have passed since the base event
|
if (baseEvent.repeat === 'weekly') {
|
||||||
let intervalsPassed = 0
|
const repeatWeekdays = baseEvent.repeatWeekdays
|
||||||
const timeDiff = targetDate - baseStartDate
|
const targetWeekday = targetDate.getDay()
|
||||||
|
if (!repeatWeekdays[targetWeekday]) continue
|
||||||
switch (baseEvent.repeat) {
|
if (targetDate < baseStartDate) continue
|
||||||
case 'daily':
|
|
||||||
intervalsPassed = Math.floor(timeDiff / (24 * 60 * 60 * 1000))
|
|
||||||
break
|
|
||||||
case 'weekly':
|
|
||||||
intervalsPassed = Math.floor(timeDiff / (7 * 24 * 60 * 60 * 1000))
|
|
||||||
break
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
const maxOccurrences = baseEvent.repeatCount === 'unlimited' ? Infinity : parseInt(baseEvent.repeatCount, 10)
|
||||||
if (i >= maxOccurrences) break
|
if (maxOccurrences === 0) continue
|
||||||
|
// Count occurrences from start up to (and including) target
|
||||||
const currentStart = new Date(baseStartDate)
|
let occIdx = 0
|
||||||
|
const cursor = new Date(baseStartDate)
|
||||||
|
while (cursor < targetDate && occIdx < maxOccurrences) {
|
||||||
|
if (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 (occIdx >= maxOccurrences) continue
|
||||||
|
const occStart = new Date(targetDate)
|
||||||
|
const occEnd = new Date(occStart); occEnd.setDate(occStart.getDate() + spanDays)
|
||||||
|
const occStartStr = toLocalString(occStart)
|
||||||
|
const occEndStr = toLocalString(occEnd)
|
||||||
|
occurrences.push({
|
||||||
|
...baseEvent,
|
||||||
|
id: `${baseEvent.id}_repeat_${occIdx}_${targetWeekday}`,
|
||||||
|
startDate: occStartStr,
|
||||||
|
endDate: occEndStr,
|
||||||
|
isRepeatOccurrence: true,
|
||||||
|
repeatIndex: occIdx
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
// Handle other repeat types (biweekly, monthly, yearly)
|
||||||
|
let intervalsPassed = 0
|
||||||
|
const timeDiff = targetDate - baseStartDate
|
||||||
|
|
||||||
switch (baseEvent.repeat) {
|
switch (baseEvent.repeat) {
|
||||||
case 'daily':
|
|
||||||
currentStart.setDate(baseStartDate.getDate() + i)
|
|
||||||
break
|
|
||||||
case 'weekly':
|
|
||||||
currentStart.setDate(baseStartDate.getDate() + i * 7)
|
|
||||||
break
|
|
||||||
case 'biweekly':
|
case 'biweekly':
|
||||||
currentStart.setDate(baseStartDate.getDate() + i * 14)
|
intervalsPassed = Math.floor(timeDiff / (14 * 24 * 60 * 60 * 1000))
|
||||||
break
|
break
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
currentStart.setMonth(baseStartDate.getMonth() + i)
|
intervalsPassed = Math.floor((targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
|
||||||
|
(targetDate.getMonth() - baseStartDate.getMonth()))
|
||||||
break
|
break
|
||||||
case 'yearly':
|
case 'yearly':
|
||||||
currentStart.setFullYear(baseStartDate.getFullYear() + i)
|
intervalsPassed = targetDate.getFullYear() - baseStartDate.getFullYear()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentEnd = new Date(currentStart)
|
// Check a few occurrences around the target date
|
||||||
currentEnd.setDate(currentStart.getDate() + spanDays)
|
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
|
||||||
|
|
||||||
// Check if this occurrence intersects with the target date
|
const currentStart = new Date(baseStartDate)
|
||||||
const currentStartStr = toLocalString(currentStart)
|
|
||||||
const currentEndStr = toLocalString(currentEnd)
|
|
||||||
|
|
||||||
if (currentStartStr <= targetDateStr && targetDateStr <= currentEndStr) {
|
switch (baseEvent.repeat) {
|
||||||
// Skip the original occurrence (i === 0) since it's already in the base events
|
case 'biweekly':
|
||||||
if (i === 0) continue
|
currentStart.setDate(baseStartDate.getDate() + i * 14)
|
||||||
|
break
|
||||||
|
case 'monthly':
|
||||||
|
currentStart.setMonth(baseStartDate.getMonth() + i)
|
||||||
|
break
|
||||||
|
case 'yearly':
|
||||||
|
currentStart.setFullYear(baseStartDate.getFullYear() + i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
occurrences.push({
|
const currentEnd = new Date(currentStart)
|
||||||
...baseEvent,
|
currentEnd.setDate(currentStart.getDate() + spanDays)
|
||||||
id: `${baseEvent.id}_repeat_${i}`,
|
|
||||||
startDate: currentStartStr,
|
// Check if this occurrence intersects with the target date
|
||||||
endDate: currentEndStr,
|
const currentStartStr = toLocalString(currentStart)
|
||||||
isRepeatOccurrence: true,
|
const currentEndStr = toLocalString(currentEnd)
|
||||||
repeatIndex: i
|
|
||||||
})
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -145,9 +171,9 @@ function getOriginalEventId(eventId) {
|
|||||||
|
|
||||||
// Handle event click
|
// Handle event click
|
||||||
function handleEventClick(span) {
|
function handleEventClick(span) {
|
||||||
// Only emit click if we didn't just finish dragging
|
|
||||||
if (justDragged.value) return
|
if (justDragged.value) return
|
||||||
emit('event-click', getOriginalEventId(span.id))
|
// Emit the actual span id (may include repeat suffix) so edit dialog knows occurrence context
|
||||||
|
emit('event-click', span.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle event pointer down for dragging
|
// Handle event pointer down for dragging
|
||||||
@ -384,13 +410,17 @@ function applyRangeDuringDrag(st, startDate, endDate) {
|
|||||||
const ev = store.getEventById(st.id)
|
const ev = store.getEventById(st.id)
|
||||||
if (!ev) return
|
if (!ev) return
|
||||||
if (ev.isRepeatOccurrence) {
|
if (ev.isRepeatOccurrence) {
|
||||||
const [baseId, idxStr] = String(st.id).split('_repeat_')
|
const idParts = String(st.id).split('_repeat_')
|
||||||
const repeatIndex = parseInt(idxStr, 10) || 0
|
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
|
||||||
|
|
||||||
if (repeatIndex === 0) {
|
if (repeatIndex === 0) {
|
||||||
store.setEventRange(baseId, startDate, endDate)
|
store.setEventRange(baseId, startDate, endDate)
|
||||||
} else {
|
} else {
|
||||||
if (!st.splitNewBaseId) {
|
if (!st.splitNewBaseId) {
|
||||||
const newId = store.splitRepeatSeries(baseId, repeatIndex, startDate, endDate)
|
const newId = store.splitRepeatSeries(baseId, repeatIndex, startDate, endDate, grabbedWeekday)
|
||||||
if (newId) {
|
if (newId) {
|
||||||
st.splitNewBaseId = newId
|
st.splitNewBaseId = newId
|
||||||
st.id = newId
|
st.id = newId
|
||||||
|
@ -54,6 +54,7 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null,
|
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null,
|
||||||
repeat: eventData.repeat || 'none',
|
repeat: eventData.repeat || 'none',
|
||||||
repeatCount: eventData.repeatCount || 'unlimited',
|
repeatCount: eventData.repeatCount || 'unlimited',
|
||||||
|
repeatWeekdays: eventData.repeatWeekdays,
|
||||||
isRepeating: (eventData.repeat && eventData.repeat !== 'none')
|
isRepeating: (eventData.repeat && eventData.repeat !== 'none')
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,6 +121,97 @@ 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 base = this.getEventById(baseId)
|
||||||
|
if (!base || base.repeat !== 'weekly') 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)))
|
||||||
|
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||||
|
if (remaining === '0') return
|
||||||
|
// Find date of next occurrence
|
||||||
|
const startDate = new Date(base.startDate + 'T00:00:00')
|
||||||
|
let idx = 0
|
||||||
|
let cur = new Date(startDate)
|
||||||
|
while (idx <= occurrenceIndex && idx < 10000) {
|
||||||
|
cur.setDate(cur.getDate() + 1)
|
||||||
|
if (base.repeatWeekdays[cur.getDay()]) idx++
|
||||||
|
}
|
||||||
|
const nextStartStr = toLocalString(cur)
|
||||||
|
this.createEvent({
|
||||||
|
title: base.title,
|
||||||
|
startDate: nextStartStr,
|
||||||
|
endDate: nextStartStr,
|
||||||
|
colorId: base.colorId,
|
||||||
|
repeat: 'weekly',
|
||||||
|
repeatCount: remaining,
|
||||||
|
repeatWeekdays: base.repeatWeekdays
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFromOccurrence(ctx) {
|
||||||
|
const { baseId, occurrenceIndex } = ctx
|
||||||
|
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteFirstOccurrence(baseId) {
|
||||||
|
const base = this.getEventById(baseId)
|
||||||
|
if (!base || !base.isRepeating) return
|
||||||
|
const oldStart = new Date(fromLocalString(base.startDate))
|
||||||
|
const oldEnd = new Date(fromLocalString(base.endDate))
|
||||||
|
const spanDays = Math.round((oldEnd - oldStart) / (24 * 60 * 60 * 1000))
|
||||||
|
let newStart = null
|
||||||
|
|
||||||
|
if (base.repeat === 'weekly' && base.repeatWeekdays) {
|
||||||
|
const probe = new Date(oldStart)
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
} else if (base.repeat === 'biweekly') {
|
||||||
|
newStart = new Date(oldStart)
|
||||||
|
newStart.setDate(newStart.getDate() + 14)
|
||||||
|
} else if (base.repeat === 'monthly') {
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newStart) {
|
||||||
|
// No subsequent occurrence -> delete entire series
|
||||||
|
this.deleteEvent(baseId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base.repeatCount !== 'unlimited') {
|
||||||
|
const rc = parseInt(base.repeatCount, 10)
|
||||||
|
if (!isNaN(rc)) {
|
||||||
|
const newRc = Math.max(0, rc - 1)
|
||||||
|
if (newRc === 0) {
|
||||||
|
this.deleteEvent(baseId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base.repeatCount = String(newRc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEnd = new Date(newStart)
|
||||||
|
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) {
|
updateEvent(eventId, updates) {
|
||||||
// Remove event from current dates
|
// Remove event from current dates
|
||||||
for (const [dateStr, eventList] of this.events) {
|
for (const [dateStr, eventList] of this.events) {
|
||||||
@ -143,23 +235,77 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
setEventRange(eventId, startDate, endDate) {
|
setEventRange(eventId, startDate, endDate) {
|
||||||
const snapshot = this._snapshotBaseEvent(eventId)
|
const snapshot = this._snapshotBaseEvent(eventId)
|
||||||
if (!snapshot) return
|
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._removeEventFromAllDatesById(eventId)
|
||||||
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
|
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
|
||||||
},
|
},
|
||||||
|
|
||||||
splitRepeatSeries(baseId, index, startDate, endDate) {
|
splitRepeatSeries(baseId, index, startDate, endDate, grabbedWeekday = null) {
|
||||||
const base = this.getEventById(baseId)
|
const base = this.getEventById(baseId)
|
||||||
if (!base) return null
|
if (!base) return null
|
||||||
|
|
||||||
const originalRepeatCount = base.repeatCount
|
const originalRepeatCount = base.repeatCount
|
||||||
|
// Always cap original series at the split occurrence index (occurrences 0..index-1)
|
||||||
|
// Keep its weekday pattern unchanged.
|
||||||
this._terminateRepeatSeriesAtIndex(baseId, index)
|
this._terminateRepeatSeriesAtIndex(baseId, index)
|
||||||
|
|
||||||
let newRepeatCount = 'unlimited'
|
let newRepeatCount = 'unlimited'
|
||||||
if (originalRepeatCount !== 'unlimited') {
|
if (originalRepeatCount !== 'unlimited') {
|
||||||
const originalCount = parseInt(originalRepeatCount, 10)
|
const originalCount = parseInt(originalRepeatCount, 10)
|
||||||
const remaining = originalCount - index
|
if (!isNaN(originalCount)) {
|
||||||
newRepeatCount = remaining > 0 ? String(remaining) : '0'
|
const remaining = originalCount - index
|
||||||
|
// remaining occurrences go to new series; ensure at least 1 (the dragged occurrence itself)
|
||||||
|
newRepeatCount = remaining > 0 ? String(remaining) : '1'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Original was unlimited: original now capped, new stays unlimited
|
||||||
|
newRepeatCount = 'unlimited'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle weekdays for weekly repeats
|
||||||
|
let newRepeatWeekdays = base.repeatWeekdays
|
||||||
|
if (base.repeat === 'weekly' && 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
|
||||||
|
} else {
|
||||||
|
// Fallback: rotate by difference between new and original start weekday
|
||||||
|
const originalStartDate = new Date(fromLocalString(base.startDate))
|
||||||
|
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 (base.repeatWeekdays[i]) {
|
||||||
|
let nd = (i + dayShift) % 7
|
||||||
|
if (nd < 0) nd += 7
|
||||||
|
rotatedWeekdays[nd] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newRepeatWeekdays = rotatedWeekdays
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const newId = this.createEvent({
|
const newId = this.createEvent({
|
||||||
@ -168,7 +314,8 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
endDate,
|
endDate,
|
||||||
colorId: base.colorId,
|
colorId: base.colorId,
|
||||||
repeat: base.repeat,
|
repeat: base.repeat,
|
||||||
repeatCount: newRepeatCount
|
repeatCount: newRepeatCount,
|
||||||
|
repeatWeekdays: newRepeatWeekdays
|
||||||
})
|
})
|
||||||
return newId
|
return newId
|
||||||
},
|
},
|
||||||
@ -229,13 +376,16 @@ export const useCalendarStore = defineStore('calendar', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_terminateRepeatSeriesAtIndex(baseId, index) {
|
_terminateRepeatSeriesAtIndex(baseId, index) {
|
||||||
// Reduce repeatCount of base series to the given index
|
// Cap repeatCount to 'index' total occurrences (0-based index means index occurrences before split)
|
||||||
for (const [, list] of this.events) {
|
for (const [, list] of this.events) {
|
||||||
for (const ev of list) {
|
for (const ev of list) {
|
||||||
if (ev.id === baseId && ev.isRepeating) {
|
if (ev.id === baseId && ev.isRepeating) {
|
||||||
const rc = ev.repeatCount === 'unlimited' ? Infinity : parseInt(ev.repeatCount, 10)
|
if (ev.repeatCount === 'unlimited') {
|
||||||
const newCount = Math.min(isFinite(rc) ? rc : index, index)
|
ev.repeatCount = String(index)
|
||||||
ev.repeatCount = String(newCount)
|
} else {
|
||||||
|
const rc = parseInt(ev.repeatCount, 10)
|
||||||
|
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user