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