@@ -339,4 +471,45 @@ defineExpose({
.ec-btn.delete-btn:hover {
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;
+}
diff --git a/src/components/EventOverlay.vue b/src/components/EventOverlay.vue
index b5c7add..9180f7b 100644
--- a/src/components/EventOverlay.vue
+++ b/src/components/EventOverlay.vue
@@ -60,73 +60,99 @@ function generateRepeatOccurrencesForDate(targetDateStr) {
const baseEndDate = new Date(fromLocalString(baseEvent.endDate))
const spanDays = Math.floor((baseEndDate - baseStartDate) / (24 * 60 * 60 * 1000))
- // Calculate how many intervals have passed since the base event
- let intervalsPassed = 0
- const timeDiff = targetDate - baseStartDate
-
- switch (baseEvent.repeat) {
- 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++) {
+ if (baseEvent.repeat === 'weekly') {
+ 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)
- if (i >= maxOccurrences) break
-
- const currentStart = new Date(baseStartDate)
+ if (maxOccurrences === 0) continue
+ // Count occurrences from start up to (and including) target
+ 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) {
- case 'daily':
- currentStart.setDate(baseStartDate.getDate() + i)
- break
- case 'weekly':
- currentStart.setDate(baseStartDate.getDate() + i * 7)
- break
case 'biweekly':
- currentStart.setDate(baseStartDate.getDate() + i * 14)
+ intervalsPassed = Math.floor(timeDiff / (14 * 24 * 60 * 60 * 1000))
break
case 'monthly':
- currentStart.setMonth(baseStartDate.getMonth() + i)
+ intervalsPassed = Math.floor((targetDate.getFullYear() - baseStartDate.getFullYear()) * 12 +
+ (targetDate.getMonth() - baseStartDate.getMonth()))
break
case 'yearly':
- currentStart.setFullYear(baseStartDate.getFullYear() + i)
+ intervalsPassed = targetDate.getFullYear() - baseStartDate.getFullYear()
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
+ // 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
- occurrences.push({
- ...baseEvent,
- id: `${baseEvent.id}_repeat_${i}`,
- startDate: currentStartStr,
- endDate: currentEndStr,
- isRepeatOccurrence: true,
- repeatIndex: i
- })
+ 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
+ })
+ }
}
}
}
@@ -145,9 +171,9 @@ function getOriginalEventId(eventId) {
// Handle event click
function handleEventClick(span) {
- // Only emit click if we didn't just finish dragging
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
@@ -384,13 +410,17 @@ function applyRangeDuringDrag(st, startDate, endDate) {
const ev = store.getEventById(st.id)
if (!ev) return
if (ev.isRepeatOccurrence) {
- const [baseId, idxStr] = String(st.id).split('_repeat_')
- const repeatIndex = parseInt(idxStr, 10) || 0
+ 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
+
if (repeatIndex === 0) {
store.setEventRange(baseId, startDate, endDate)
} else {
if (!st.splitNewBaseId) {
- const newId = store.splitRepeatSeries(baseId, repeatIndex, startDate, endDate)
+ const newId = store.splitRepeatSeries(baseId, repeatIndex, startDate, endDate, grabbedWeekday)
if (newId) {
st.splitNewBaseId = newId
st.id = newId
diff --git a/src/stores/CalendarStore.js b/src/stores/CalendarStore.js
index 94456dd..fb6a0e5 100644
--- a/src/stores/CalendarStore.js
+++ b/src/stores/CalendarStore.js
@@ -54,6 +54,7 @@ export const useCalendarStore = defineStore('calendar', {
durationMinutes: singleDay ? (eventData.durationMinutes || 60) : null,
repeat: eventData.repeat || 'none',
repeatCount: eventData.repeatCount || 'unlimited',
+ repeatWeekdays: eventData.repeatWeekdays,
isRepeating: (eventData.repeat && eventData.repeat !== 'none')
}
@@ -120,6 +121,97 @@ export const useCalendarStore = defineStore('calendar', {
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) {
// Remove event from current dates
for (const [dateStr, eventList] of this.events) {
@@ -143,23 +235,77 @@ export const useCalendarStore = defineStore('calendar', {
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) {
+ splitRepeatSeries(baseId, index, startDate, endDate, grabbedWeekday = null) {
const base = this.getEventById(baseId)
if (!base) return null
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)
let newRepeatCount = 'unlimited'
if (originalRepeatCount !== 'unlimited') {
const originalCount = parseInt(originalRepeatCount, 10)
- const remaining = originalCount - index
- newRepeatCount = remaining > 0 ? String(remaining) : '0'
+ if (!isNaN(originalCount)) {
+ 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({
@@ -168,7 +314,8 @@ export const useCalendarStore = defineStore('calendar', {
endDate,
colorId: base.colorId,
repeat: base.repeat,
- repeatCount: newRepeatCount
+ repeatCount: newRepeatCount,
+ repeatWeekdays: newRepeatWeekdays
})
return newId
},
@@ -229,13 +376,16 @@ export const useCalendarStore = defineStore('calendar', {
},
_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 ev of list) {
if (ev.id === baseId && ev.isRepeating) {
- const rc = ev.repeatCount === 'unlimited' ? Infinity : parseInt(ev.repeatCount, 10)
- const newCount = Math.min(isFinite(rc) ? rc : index, index)
- ev.repeatCount = String(newCount)
+ if (ev.repeatCount === 'unlimited') {
+ ev.repeatCount = String(index)
+ } else {
+ const rc = parseInt(ev.repeatCount, 10)
+ if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
+ }
}
}
}