Major new version #2

Merged
LeoVasanko merged 86 commits from vol002 into main 2025-08-26 05:58:24 +01:00
2 changed files with 152 additions and 317 deletions
Showing only changes of commit 8e926c0a21 - Show all commits

View File

@ -5,9 +5,10 @@ import {
getLocaleWeekendDays, getLocaleWeekendDays,
getMondayOfISOWeek, getMondayOfISOWeek,
getOccurrenceIndex, getOccurrenceIndex,
getOccurrenceDate,
DEFAULT_TZ, DEFAULT_TZ,
} from '@/utils/date' } from '@/utils/date'
import { differenceInCalendarDays, addDays, addMonths } from 'date-fns' import { differenceInCalendarDays, addDays } from 'date-fns'
import { import {
initializeHolidays, initializeHolidays,
getHolidayForDate, getHolidayForDate,
@ -235,249 +236,100 @@ export const useCalendarStore = defineStore('calendar', {
}, },
deleteEvent(eventId) { deleteEvent(eventId) {
console.log('Deleting event', eventId)
this.events.delete(eventId) this.events.delete(eventId)
}, },
deleteSingleOccurrence(ctx) { // Remove the first (base) occurrence of a repeating event by shifting anchor forward
const { baseId, occurrenceIndex } = ctx deleteFirstOccurrence(baseId) {
console.log('Deleting first occurrence', baseId)
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base || !base.isRepeating) return if (!base) return
// WEEKLY SERIES ------------------------------------------------------ if (!base.isRepeating) {
if (base.repeat === 'weeks') { // Simple (non-repeating) event: delete entirely
// Special case: deleting the first occurrence (index 0) should shift the series forward this.deleteEvent(baseId)
if (occurrenceIndex === 0) {
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart))
const pattern = base.repeatWeekdays || []
if (!pattern.some(Boolean)) {
// No pattern to continue -> delete whole series
this.deleteEvent(baseId)
return
}
const interval = base.repeatInterval || 1
const WEEK_MS = 7 * 86400000
const baseBlockStart = getMondayOfISOWeek(baseStart)
const isAligned = (d) => {
const blk = getMondayOfISOWeek(d)
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
return diff % interval === 0
}
let probe = new Date(baseStart)
let safety = 0
let found = null
while (safety < 5000) {
probe = addDays(probe, 1)
if (pattern[probe.getDay()] && isAligned(probe)) {
found = new Date(probe)
break
}
safety++
}
if (!found) {
// Nothing after first -> delete series
this.deleteEvent(baseId)
return
}
// Adjust repeat count
if (base.repeatCount !== 'unlimited') {
const rc = parseInt(base.repeatCount, 10)
if (!isNaN(rc)) {
const newRc = rc - 1
if (newRc <= 0) {
this.deleteEvent(baseId)
return
}
base.repeatCount = String(newRc)
}
}
const newEnd = addDays(found, spanDays)
base.startDate = toLocalString(found, DEFAULT_TZ)
base.endDate = toLocalString(newEnd, DEFAULT_TZ)
base.isSpanning = base.startDate < base.endDate
this.events.set(base.id, base)
return
}
const interval = base.repeatInterval || 1
const pattern = base.repeatWeekdays || []
if (!pattern.some(Boolean)) return
// Preserve original count before any truncation
const originalCountRaw = base.repeatCount
// Determine target occurrence date
let targetDate = null
if (ctx.occurrenceDate instanceof Date) {
targetDate = new Date(
ctx.occurrenceDate.getFullYear(),
ctx.occurrenceDate.getMonth(),
ctx.occurrenceDate.getDate(),
)
} else {
// Fallback: derive from sequential occurrenceIndex (base=0, first repeat=1)
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ)
if (occurrenceIndex === 0) {
targetDate = baseStart
} else {
let cur = new Date(baseEnd)
cur = addDays(cur, 1)
let found = 0
let safety = 0
const WEEK_MS = 7 * 86400000
const baseBlockStart = getMondayOfISOWeek(baseStart)
function isAligned(d) {
const blk = getMondayOfISOWeek(d)
const diff = Math.floor((blk - baseBlockStart) / WEEK_MS)
return diff % interval === 0
}
while (found < occurrenceIndex && safety < 50000) {
if (pattern[cur.getDay()] && isAligned(cur)) {
found++
if (found === occurrenceIndex) break
}
cur = addDays(cur, 1)
safety++
}
targetDate = cur
}
}
if (!targetDate) return
// Count occurrences BEFORE target (always include the base occurrence as first)
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const baseBlockStart = getMondayOfISOWeek(baseStart)
const WEEK_MS = 7 * 86400000
function isAligned(d) {
const block = getMondayOfISOWeek(d)
const diff = Math.floor((block - baseBlockStart) / WEEK_MS)
return diff % interval === 0
}
// Start with 1 (base occurrence) if target is after base; if target IS base (should not happen here) leave 0
let countBefore = targetDate > baseStart ? 1 : 0
let probe = new Date(baseStart)
probe = addDays(probe, 1) // start counting AFTER base
let safety2 = 0
while (probe < targetDate && safety2 < 50000) {
if (pattern[probe.getDay()] && isAligned(probe)) countBefore++
probe = addDays(probe, 1)
safety2++
}
// Terminate original series to keep only occurrences before target
this._terminateRepeatSeriesAtIndex(baseId, countBefore)
// Calculate remaining occurrences for new series using ORIGINAL total
let remainingCount = 'unlimited'
if (originalCountRaw !== 'unlimited') {
const originalTotal = parseInt(originalCountRaw, 10)
if (!isNaN(originalTotal)) {
const rem = originalTotal - countBefore - 1 // kept + deleted
if (rem <= 0) return // nothing left to continue
remainingCount = String(rem)
}
}
// Continuation starts at NEXT valid occurrence (matching weekday & aligned block)
let continuationStart = new Date(targetDate)
let searchSafety = 0
let foundNext = false
while (searchSafety < 50000) {
continuationStart = addDays(continuationStart, 1)
if (pattern[continuationStart.getDay()] && isAligned(continuationStart)) {
foundNext = true
break
}
searchSafety++
}
if (!foundNext) return // no remaining occurrences
const spanDays = differenceInCalendarDays(
fromLocalString(base.endDate, DEFAULT_TZ),
fromLocalString(base.startDate, DEFAULT_TZ),
)
const nextStartStr = toLocalString(continuationStart, DEFAULT_TZ)
const nextEnd = addDays(continuationStart, spanDays)
const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ)
this.createEvent({
title: base.title,
startDate: nextStartStr,
endDate: nextEndStr,
colorId: base.colorId,
repeat: 'weeks',
repeatInterval: interval,
repeatCount: remainingCount,
repeatWeekdays: base.repeatWeekdays,
})
return return
} }
// MONTHLY SERIES ----------------------------------------------------- const numericCount =
if (base.repeat === 'months') { base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10)
if (occurrenceIndex === 0) { if (numericCount <= 1) {
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) // Only one occurrence (or invalid count) -> delete event
const baseEnd = fromLocalString(base.endDate, DEFAULT_TZ) this.deleteEvent(baseId)
const spanDays = Math.max(0, differenceInCalendarDays(baseEnd, baseStart)) return
const interval = base.repeatInterval || 1
const targetMonthIndex = baseStart.getMonth() + interval
const targetYear = baseStart.getFullYear() + Math.floor(targetMonthIndex / 12)
const targetMonth = targetMonthIndex % 12
const daysInTarget = new Date(targetYear, targetMonth + 1, 0).getDate()
const dom = Math.min(baseStart.getDate(), daysInTarget)
const newStart = new Date(targetYear, targetMonth, dom)
if (base.repeatCount !== 'unlimited') {
const rc = parseInt(base.repeatCount, 10)
if (!isNaN(rc)) {
const newRc = rc - 1
if (newRc <= 0) {
this.deleteEvent(baseId)
return
}
base.repeatCount = String(newRc)
}
}
const newEnd = addDays(newStart, spanDays)
base.startDate = toLocalString(newStart, DEFAULT_TZ)
base.endDate = toLocalString(newEnd, DEFAULT_TZ)
base.isSpanning = base.startDate < base.endDate
this.events.set(base.id, base)
return
}
const interval = base.repeatInterval || 1
// Sequential index: base=0, first repeat=1
if (occurrenceIndex <= 0) return // base deletion handled elsewhere
// Count prior occurrences to KEEP (indices 0 .. occurrenceIndex-1) => occurrenceIndex total
const originalCountRaw = base.repeatCount
const priorOccurrences = occurrenceIndex
this._terminateRepeatSeriesAtIndex(baseId, priorOccurrences)
// Compute span days for multiday events
const spanDays = differenceInCalendarDays(
fromLocalString(base.endDate, DEFAULT_TZ),
fromLocalString(base.startDate, DEFAULT_TZ),
)
// Remaining occurrences after deletion
let remainingCount = 'unlimited'
if (originalCountRaw !== 'unlimited') {
const total = parseInt(originalCountRaw, 10)
if (!isNaN(total)) {
const rem = total - priorOccurrences - 1 // subtract kept + deleted
if (rem <= 0) return // nothing left
remainingCount = String(rem)
}
}
// Next occurrence after deleted one is at (occurrenceIndex + 1)*interval months from base
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
const nextStart = addMonths(baseStart, (occurrenceIndex + 1) * interval)
const nextEnd = addDays(nextStart, spanDays)
const nextStartStr = toLocalString(nextStart, DEFAULT_TZ)
const nextEndStr = toLocalString(nextEnd, DEFAULT_TZ)
this.createEvent({
title: base.title,
startDate: nextStartStr,
endDate: nextEndStr,
colorId: base.colorId,
repeat: 'months',
repeatInterval: interval,
repeatCount: remainingCount,
})
} }
// Get the next occurrence start date (index 1)
const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ)
if (!nextStartStr) {
// No next occurrence; remove event
this.deleteEvent(baseId)
return
}
const oldStart = fromLocalString(base.startDate, DEFAULT_TZ)
const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const durationDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart))
const newEndStr = toLocalString(
addDays(fromLocalString(nextStartStr, DEFAULT_TZ), durationDays),
DEFAULT_TZ,
)
// Mutate existing event instead of delete+recreate so references remain stable
base.startDate = nextStartStr
base.endDate = newEndStr
if (numericCount !== Infinity) {
base.repeatCount = String(Math.max(1, numericCount - 1))
}
this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate })
},
// Delete a specific occurrence (not the first) from a repeating series, splitting into two
deleteSingleOccurrence(ctx) {
console.log('DeletesingleOccurrence')
const { baseId, occurrenceIndex } = ctx || {}
if (occurrenceIndex === undefined || occurrenceIndex === null) return
const base = this.getEventById(baseId)
if (!base) return
if (!base.isRepeating) {
// Single non-repeating event deletion
if (occurrenceIndex === 0) this.deleteEvent(baseId)
return
}
if (occurrenceIndex === 0) {
// Delegate to specialized first-occurrence deletion
this.deleteFirstOccurrence(baseId)
return
}
// Save copy before truncation for computing next occurrence date
const snapshot = { ...base }
// Cap original series to occurrences before the deleted one
base.repeatCount = occurrenceIndex
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
console.log('Deleting single', occurrenceIndex, nextStartStr)
if (!nextStartStr) return // no continuation
const durationDays = Math.max(
0,
differenceInCalendarDays(
fromLocalString(snapshot.endDate),
fromLocalString(snapshot.startDate),
),
)
const newEndStr = toLocalString(addDays(fromLocalString(nextStartStr), durationDays))
const originalNumeric =
snapshot.repeatCount === 'unlimited' ? Infinity : parseInt(snapshot.repeatCount, 10)
let remainingCount = 'unlimited'
if (originalNumeric !== Infinity) {
const rem = originalNumeric - (occurrenceIndex + 1)
if (rem <= 0) return
remainingCount = String(rem)
}
this.createEvent({
title: snapshot.title,
startDate: nextStartStr,
endDate: newEndStr,
colorId: snapshot.colorId,
repeat: snapshot.repeat,
repeatInterval: snapshot.repeatInterval,
repeatCount: remainingCount,
repeatWeekdays: snapshot.repeatWeekdays,
})
}, },
deleteFromOccurrence(ctx) { deleteFromOccurrence(ctx) {
@ -494,86 +346,6 @@ export const useCalendarStore = defineStore('calendar', {
this._terminateRepeatSeriesAtIndex(baseId, keptTotal) this._terminateRepeatSeriesAtIndex(baseId, keptTotal)
}, },
deleteFirstOccurrence(baseId) {
const base = this.getEventById(baseId)
if (!base || !base.isRepeating) return
const oldStart = fromLocalString(base.startDate, DEFAULT_TZ)
const oldEnd = fromLocalString(base.endDate, DEFAULT_TZ)
const spanDays = Math.max(0, differenceInCalendarDays(oldEnd, oldStart))
let newStartDate = null
if (base.repeat === 'weeks') {
const pattern = base.repeatWeekdays || []
if (!pattern.some(Boolean)) {
// No valid pattern -> delete series
this.deleteEvent(baseId)
return
}
const interval = base.repeatInterval || 1
const baseBlockStart = getMondayOfISOWeek(oldStart)
const WEEK_MS = 7 * 86400000
const isAligned = (d) => {
const block = getMondayOfISOWeek(d)
const diff = Math.floor((block - baseBlockStart) / WEEK_MS)
return diff % interval === 0
}
// search forward for next valid weekday respecting interval alignment
let probe = new Date(oldStart)
let safety = 0
while (safety < 5000) {
probe = addDays(probe, 1)
if (pattern[probe.getDay()] && isAligned(probe)) {
newStartDate = new Date(probe)
break
}
safety++
}
} else if (base.repeat === 'months') {
const interval = base.repeatInterval || 1
const y = oldStart.getFullYear()
const m = oldStart.getMonth()
const targetMonthIndex = m + interval
const targetYear = y + Math.floor(targetMonthIndex / 12)
const targetMonth = targetMonthIndex % 12
const daysInTargetMonth = new Date(targetYear, targetMonth + 1, 0).getDate()
const dom = Math.min(oldStart.getDate(), daysInTargetMonth)
newStartDate = new Date(targetYear, targetMonth, dom)
} else {
// Unsupported repeat type
this.deleteEvent(baseId)
return
}
if (!newStartDate) {
// No continuation; deleting first removes series
this.deleteEvent(baseId)
return
}
// Decrement repeatCount if limited
if (base.repeatCount !== 'unlimited') {
const rc = parseInt(base.repeatCount, 10)
if (!isNaN(rc)) {
const newRc = rc - 1
if (newRc <= 0) {
// After removing first occurrence there are none left
this.deleteEvent(baseId)
return
}
base.repeatCount = String(newRc)
}
}
const newEndDate = addDays(newStartDate, spanDays)
base.startDate = toLocalString(newStartDate, DEFAULT_TZ)
base.endDate = toLocalString(newEndDate, DEFAULT_TZ)
base.isSpanning = base.startDate < base.endDate
// Persist updated base event
this.events.set(base.id, base)
return base.id
},
// Adjust start/end range of a base event (non-generated) and reindex occurrences // Adjust start/end range of a base event (non-generated) and reindex occurrences
setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) { setEventRange(eventId, newStartStr, newEndStr, { mode = 'auto' } = {}) {
const snapshot = this.events.get(eventId) const snapshot = this.events.get(eventId)

View File

@ -139,6 +139,68 @@ function getOccurrenceIndex(event, dateStr, timeZone = DEFAULT_TZ) {
return null 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
if (occurrenceIndex < 0 || !Number.isInteger(occurrenceIndex)) return null
if (event.repeatCount !== 'unlimited' && occurrenceIndex >= event.repeatCount) return null
const pattern = event.repeatWeekdays || []
if (!pattern.some(Boolean)) return null
const interval = event.repeatInterval || 1
const baseStart = fromLocalString(event.startDate, timeZone)
if (occurrenceIndex === 0) return toLocalString(baseStart, timeZone)
const baseWeekMonday = getMondayOfISOWeek(baseStart, timeZone)
const baseDow = dateFns.getDay(baseStart)
// Sorted list of active weekday indices
const patternDays = []
for (let d = 0; d < 7; d++) if (pattern[d]) patternDays.push(d)
// First (possibly partial) week: only pattern days >= baseDow and >= baseStart date
const firstWeekDates = []
for (const d of patternDays) {
if (d < baseDow) continue
const date = dateFns.addDays(baseWeekMonday, d)
if (date < baseStart) continue
firstWeekDates.push(date)
}
const F = firstWeekDates.length
if (occurrenceIndex < F) {
return toLocalString(firstWeekDates[occurrenceIndex], timeZone)
}
const remaining = occurrenceIndex - F
const P = patternDays.length
if (P === 0) return null
// Determine aligned week group (k >= 1) in which the remaining-th occurrence lies
const k = Math.floor(remaining / P) + 1 // 1-based aligned week count after base week
const indexInWeek = remaining % P
const dow = patternDays[indexInWeek]
const occurrenceDate = dateFns.addDays(baseWeekMonday, k * interval * 7 + dow)
return toLocalString(occurrenceDate, timeZone)
}
function getMonthlyOccurrenceDate(event, occurrenceIndex, timeZone = DEFAULT_TZ) {
if (!event?.isRepeating || event.repeat !== '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 baseStart = fromLocalString(event.startDate, timeZone)
const targetMonthOffset = occurrenceIndex * interval
const monthDate = dateFns.addMonths(baseStart, targetMonthOffset)
// Adjust day for shorter months (clamp like forward logic)
const baseDay = dateFns.getDate(baseStart)
const daysInTargetMonth = dateFns.getDaysInMonth(monthDate)
const day = Math.min(baseDay, daysInTargetMonth)
const actual = makeTZDate(dateFns.getYear(monthDate), dateFns.getMonth(monthDate), day, timeZone)
return toLocalString(actual, timeZone)
}
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)
return null
}
function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) { function getVirtualOccurrenceEndDate(event, occurrenceStartDate, timeZone = DEFAULT_TZ) {
const baseStart = fromLocalString(event.startDate, timeZone) const baseStart = fromLocalString(event.startDate, timeZone)
const baseEnd = fromLocalString(event.endDate, timeZone) const baseEnd = fromLocalString(event.endDate, timeZone)
@ -237,6 +299,7 @@ export {
getMondayOfISOWeek, getMondayOfISOWeek,
mondayIndex, mondayIndex,
getOccurrenceIndex, getOccurrenceIndex,
getOccurrenceDate,
getVirtualOccurrenceEndDate, getVirtualOccurrenceEndDate,
// formatting & localization // formatting & localization
pad, pad,