503
									
								
								src/stores/CalendarStore.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										503
									
								
								src/stores/CalendarStore.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,503 @@ | ||||
| import { defineStore } from 'pinia' | ||||
| import { | ||||
|   toLocalString, | ||||
|   fromLocalString, | ||||
|   getLocaleFirstDay, | ||||
|   getLocaleWeekendDays, | ||||
| } from '@/utils/date' | ||||
|  | ||||
| /** | ||||
|  * Calendar configuration can be overridden via window.calendarConfig: | ||||
|  *  | ||||
|  * window.calendarConfig = { | ||||
|  *   firstDay: 0,           // 0=Sunday, 1=Monday, etc. (default: 1) | ||||
|  *   firstDay: 'auto',      // Use locale detection | ||||
|  *   weekendDays: [true, false, false, false, false, false, true], // Custom weekend | ||||
|  *   weekendDays: 'auto'    // Use locale detection (default) | ||||
|  * } | ||||
|  */ | ||||
|  | ||||
| const MIN_YEAR = 1900 | ||||
| const MAX_YEAR = 2100 | ||||
|  | ||||
| // Helper function to determine first day with config override support | ||||
| function getConfiguredFirstDay() { | ||||
|   // Check for environment variable or global config | ||||
|   const configOverride = window?.calendarConfig?.firstDay | ||||
|   if (configOverride !== undefined) { | ||||
|     return configOverride === 'auto' ? getLocaleFirstDay() : Number(configOverride) | ||||
|   } | ||||
|   // Default to Monday (1) instead of locale | ||||
|   return 1 | ||||
| } | ||||
|  | ||||
| // Helper function to determine weekend days with config override support   | ||||
| function getConfiguredWeekendDays() { | ||||
|   // Check for environment variable or global config | ||||
|   const configOverride = window?.calendarConfig?.weekendDays | ||||
|   if (configOverride !== undefined) { | ||||
|     return configOverride === 'auto' ? getLocaleWeekendDays() : configOverride | ||||
|   } | ||||
|   // Default to locale-based weekend days | ||||
|   return getLocaleWeekendDays() | ||||
| } | ||||
|  | ||||
| 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: getConfiguredWeekendDays(), | ||||
|     config: { | ||||
|       select_days: 1000, | ||||
|       min_year: MIN_YEAR, | ||||
|       max_year: MAX_YEAR, | ||||
|       first_day: getConfiguredFirstDay(), | ||||
|     }, | ||||
|   }), | ||||
|  | ||||
|   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. | ||||
|   }, | ||||
| }) | ||||
		Reference in New Issue
	
	Block a user