471 lines
16 KiB
JavaScript
471 lines
16 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import {
|
|
toLocalString,
|
|
fromLocalString,
|
|
getLocaleFirstDay,
|
|
getLocaleWeekendDays,
|
|
} from '@/utils/date'
|
|
|
|
const MIN_YEAR = 1900
|
|
const MAX_YEAR = 2100
|
|
|
|
export const useCalendarStore = defineStore('calendar', {
|
|
state: () => ({
|
|
today: toLocalString(new Date()),
|
|
now: new Date(),
|
|
events: new Map(), // Map of date strings to arrays of events
|
|
weekend: getLocaleWeekendDays(),
|
|
config: {
|
|
select_days: 1000,
|
|
min_year: MIN_YEAR,
|
|
max_year: MAX_YEAR,
|
|
first_day: getLocaleFirstDay(),
|
|
},
|
|
}),
|
|
|
|
getters: {
|
|
// Basic configuration getters
|
|
minYear: () => MIN_YEAR,
|
|
maxYear: () => MAX_YEAR,
|
|
},
|
|
|
|
actions: {
|
|
updateCurrentDate() {
|
|
this.now = new Date()
|
|
const today = toLocalString(this.now)
|
|
if (this.today !== today) {
|
|
this.today = today
|
|
}
|
|
},
|
|
|
|
// Event management
|
|
generateId() {
|
|
try {
|
|
if (window.crypto && typeof window.crypto.randomUUID === 'function') {
|
|
return window.crypto.randomUUID()
|
|
}
|
|
} catch {}
|
|
return 'e-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36)
|
|
},
|
|
|
|
createEvent(eventData) {
|
|
const singleDay = eventData.startDate === eventData.endDate
|
|
const event = {
|
|
id: this.generateId(),
|
|
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 === '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',
|
|
}
|
|
|
|
const startDate = new Date(fromLocalString(event.startDate))
|
|
const endDate = new Date(fromLocalString(event.endDate))
|
|
|
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
const dateStr = toLocalString(d)
|
|
if (!this.events.has(dateStr)) {
|
|
this.events.set(dateStr, [])
|
|
}
|
|
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)
|
|
if (found) return found
|
|
}
|
|
return null
|
|
},
|
|
|
|
selectEventColorId(startDateStr, endDateStr) {
|
|
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
|
|
const startDate = new Date(fromLocalString(startDateStr))
|
|
const endDate = new Date(fromLocalString(endDateStr))
|
|
|
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
|
const dateStr = toLocalString(d)
|
|
const dayEvents = this.events.get(dateStr) || []
|
|
for (const event of dayEvents) {
|
|
if (event.colorId >= 0 && event.colorId < 8) {
|
|
colorCounts[event.colorId]++
|
|
}
|
|
}
|
|
}
|
|
|
|
let minCount = colorCounts[0]
|
|
let selectedColor = 0
|
|
|
|
for (let colorId = 1; colorId < 8; colorId++) {
|
|
if (colorCounts[colorId] < minCount) {
|
|
minCount = colorCounts[colorId]
|
|
selectedColor = colorId
|
|
}
|
|
}
|
|
|
|
return selectedColor
|
|
},
|
|
|
|
deleteEvent(eventId) {
|
|
const datesToCleanup = []
|
|
for (const [dateStr, eventList] of this.events) {
|
|
const eventIndex = eventList.findIndex((event) => event.id === eventId)
|
|
if (eventIndex !== -1) {
|
|
eventList.splice(eventIndex, 1)
|
|
if (eventList.length === 0) {
|
|
datesToCleanup.push(dateStr)
|
|
}
|
|
}
|
|
}
|
|
datesToCleanup.forEach((dateStr) => this.events.delete(dateStr))
|
|
},
|
|
|
|
deleteSingleOccurrence(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)))
|
|
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: 'weeks',
|
|
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 === 'weeks' && 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 === 'months') {
|
|
newStart = new Date(oldStart)
|
|
newStart.setMonth(newStart.getMonth() + 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)
|
|
// 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.
|
|
this._terminateRepeatSeriesAtIndex(baseId, index)
|
|
|
|
let newRepeatCount = 'unlimited'
|
|
if (originalRepeatCount !== 'unlimited') {
|
|
const originalCount = parseInt(originalRepeatCount, 10)
|
|
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 === '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
|
|
} 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({
|
|
title: base.title,
|
|
startDate,
|
|
endDate,
|
|
colorId: base.colorId,
|
|
repeat: base.repeat,
|
|
repeatCount: newRepeatCount,
|
|
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)
|
|
if (e) return { ...e }
|
|
}
|
|
return null
|
|
},
|
|
|
|
_removeEventFromAllDatesById(eventId) {
|
|
for (const [dateStr, list] of this.events) {
|
|
for (let i = list.length - 1; i >= 0; i--) {
|
|
if (list[i].id === eventId) {
|
|
list.splice(i, 1)
|
|
}
|
|
}
|
|
if (list.length === 0) this.events.delete(dateStr)
|
|
}
|
|
},
|
|
|
|
_addEventToDateRangeWithId(eventId, baseData, startDate, endDate) {
|
|
const s = fromLocalString(startDate)
|
|
const e = fromLocalString(endDate)
|
|
const multi = startDate < endDate
|
|
const payload = {
|
|
...baseData,
|
|
id: eventId,
|
|
startDate,
|
|
endDate,
|
|
isSpanning: multi,
|
|
}
|
|
// Normalize single-day time fields
|
|
if (!multi) {
|
|
if (!payload.startTime) payload.startTime = '09:00'
|
|
if (!payload.durationMinutes) payload.durationMinutes = 60
|
|
} else {
|
|
payload.startTime = null
|
|
payload.durationMinutes = null
|
|
}
|
|
const cur = new Date(s)
|
|
while (cur <= e) {
|
|
const dateStr = toLocalString(cur)
|
|
if (!this.events.has(dateStr)) this.events.set(dateStr, [])
|
|
this.events.get(dateStr).push({ ...payload })
|
|
cur.setDate(cur.getDate() + 1)
|
|
}
|
|
},
|
|
|
|
// 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)
|
|
this._addEventToDateRangeWithId(eventId, snapshot, startDate, endDate)
|
|
},
|
|
|
|
_terminateRepeatSeriesAtIndex(baseId, 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) {
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
_findEventInAnyList(eventId) {
|
|
for (const [, eventList] of this.events) {
|
|
const found = eventList.find((e) => e.id === eventId)
|
|
if (found) return found
|
|
}
|
|
return null
|
|
},
|
|
|
|
_addEventToDateRange(event) {
|
|
const startDate = fromLocalString(event.startDate)
|
|
const endDate = fromLocalString(event.endDate)
|
|
const cur = new Date(startDate)
|
|
|
|
while (cur <= endDate) {
|
|
const dateStr = toLocalString(cur)
|
|
if (!this.events.has(dateStr)) {
|
|
this.events.set(dateStr, [])
|
|
}
|
|
this.events.get(dateStr).push({ ...event, isSpanning: event.startDate < event.endDate })
|
|
cur.setDate(cur.getDate() + 1)
|
|
}
|
|
},
|
|
|
|
// NOTE: legacy dynamic getEventById for synthetic occurrences removed.
|
|
},
|
|
})
|