calendar/src/stores/CalendarStore.js

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.
},
})