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 46 additions and 163 deletions
Showing only changes of commit fece943594 - Show all commits

View File

@ -20,6 +20,7 @@ import {
} from '@/utils/date' } from '@/utils/date'
import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date' import { toLocalString, fromLocalString, DEFAULT_TZ } from '@/utils/date'
import { addDays, differenceInCalendarDays } from 'date-fns' import { addDays, differenceInCalendarDays } from 'date-fns'
import { getHolidayForDate } from '@/utils/holidays'
const calendarStore = useCalendarStore() const calendarStore = useCalendarStore()
const viewport = ref(null) const viewport = ref(null)
@ -232,7 +233,12 @@ function createWeek(virtualWeek) {
} }
// Get holiday info once per day // Get holiday info once per day
const holiday = calendarStore.getHolidayForDate(dateStr) // Ensure holidays initialized lazily
let holiday = null
if (calendarStore.config.holidays.enabled) {
calendarStore._ensureHolidaysInitialized?.()
holiday = getHolidayForDate(dateStr)
}
days.push({ days.push({
date: dateStr, date: dateStr,

View File

@ -4,18 +4,11 @@ import {
fromLocalString, fromLocalString,
getLocaleWeekendDays, getLocaleWeekendDays,
getMondayOfISOWeek, getMondayOfISOWeek,
getOccurrenceIndex,
getOccurrenceDate, getOccurrenceDate,
DEFAULT_TZ, DEFAULT_TZ,
} from '@/utils/date' } from '@/utils/date'
import { differenceInCalendarDays, addDays } from 'date-fns' import { differenceInCalendarDays, addDays } from 'date-fns'
import { import { initializeHolidays, getAvailableCountries, getAvailableStates } from '@/utils/holidays'
initializeHolidays,
getHolidayForDate,
isHoliday,
getAvailableCountries,
getAvailableStates,
} from '@/utils/holidays'
const MIN_YEAR = 1900 const MIN_YEAR = 1900
const MAX_YEAR = 2100 const MAX_YEAR = 2100
@ -43,29 +36,23 @@ export const useCalendarStore = defineStore('calendar', {
}), }),
getters: { getters: {
// Basic configuration getters
minYear: () => MIN_YEAR, minYear: () => MIN_YEAR,
maxYear: () => MAX_YEAR, maxYear: () => MAX_YEAR,
}, },
actions: { actions: {
// Initialize holidays based on current config _resolveCountry(code) {
initializeHolidaysFromConfig() { if (!code || code !== 'auto') return code
if (!this.config.holidays.enabled) {
return false
}
let country = this.config.holidays.country
if (country === 'auto') {
const locale = navigator.language || navigator.languages?.[0] const locale = navigator.language || navigator.languages?.[0]
if (!locale) return false if (!locale) return null
const parts = locale.split('-') const parts = locale.split('-')
if (parts.length < 2) return false if (parts.length < 2) return null
return parts[parts.length - 1].toUpperCase()
country = parts[parts.length - 1].toUpperCase() },
}
initializeHolidaysFromConfig() {
if (!this.config.holidays.enabled) return false
const country = this._resolveCountry(this.config.holidays.country)
if (country) { if (country) {
return this.initializeHolidays( return this.initializeHolidays(
country, country,
@ -73,107 +60,55 @@ export const useCalendarStore = defineStore('calendar', {
this.config.holidays.region, this.config.holidays.region,
) )
} }
return false return false
}, },
occursOnDate(event, dateStr) {
return getOccurrenceIndex(event, dateStr) !== null
},
updateCurrentDate() { updateCurrentDate() {
const d = new Date() const d = new Date()
this.now = d.toISOString() this.now = d.toISOString()
const today = toLocalString(d, DEFAULT_TZ) const today = toLocalString(d, DEFAULT_TZ)
if (this.today !== today) { if (this.today !== today) this.today = today
this.today = today
}
}, },
// Holiday management
initializeHolidays(country, state = null, region = null) { initializeHolidays(country, state = null, region = null) {
let actualCountry = country const actualCountry = this._resolveCountry(country)
if (country === 'auto') { if (this.config.holidays.country !== 'auto') this.config.holidays.country = country
const locale = navigator.language || navigator.languages?.[0]
if (!locale) return false
const parts = locale.split('-')
if (parts.length < 2) return false
actualCountry = parts[parts.length - 1].toUpperCase()
}
if (this.config.holidays.country !== 'auto') {
this.config.holidays.country = country
}
this.config.holidays.state = state this.config.holidays.state = state
this.config.holidays.region = region this.config.holidays.region = region
this._holidayConfigSignature = null this._holidayConfigSignature = null
this._holidaysInitialized = false this._holidaysInitialized = false
return initializeHolidays(actualCountry, state, region) return initializeHolidays(actualCountry, state, region)
}, },
_ensureHolidaysInitialized() { _ensureHolidaysInitialized() {
if (!this.config.holidays.enabled) { if (!this.config.holidays.enabled) return false
return false const actualCountry = this._resolveCountry(this.config.holidays.country)
} const sig = `${actualCountry}-${this.config.holidays.state}-${this.config.holidays.region}-${this.config.holidays.enabled}`
if (this._holidayConfigSignature !== sig || !this._holidaysInitialized) {
let actualCountry = this.config.holidays.country const ok = initializeHolidays(
if (this.config.holidays.country === 'auto') {
const locale = navigator.language || navigator.languages?.[0]
if (!locale) return false
const parts = locale.split('-')
if (parts.length < 2) return false
actualCountry = parts[parts.length - 1].toUpperCase()
}
const configSignature = `${actualCountry}-${this.config.holidays.state}-${this.config.holidays.region}-${this.config.holidays.enabled}`
if (this._holidayConfigSignature !== configSignature || !this._holidaysInitialized) {
const success = initializeHolidays(
actualCountry, actualCountry,
this.config.holidays.state, this.config.holidays.state,
this.config.holidays.region, this.config.holidays.region,
) )
if (success) { if (ok) {
this._holidayConfigSignature = configSignature this._holidayConfigSignature = sig
this._holidaysInitialized = true this._holidaysInitialized = true
} }
return success return ok
} }
return this._holidaysInitialized return this._holidaysInitialized
}, },
getHolidayForDate(dateStr) {
if (!this._ensureHolidaysInitialized()) {
return null
}
return getHolidayForDate(dateStr)
},
isHoliday(dateStr) {
if (!this._ensureHolidaysInitialized()) {
return false
}
return isHoliday(dateStr)
},
getAvailableCountries() { getAvailableCountries() {
return getAvailableCountries() || [] return getAvailableCountries() || []
}, },
getAvailableStates(country) { getAvailableStates(country) {
return getAvailableStates(country) || [] return getAvailableStates(country) || []
}, },
toggleHolidays() { toggleHolidays() {
this.config.holidays.enabled = !this.config.holidays.enabled this.config.holidays.enabled = !this.config.holidays.enabled
}, },
// Event management
generateId() { generateId() {
try { try {
if (window.crypto && typeof window.crypto.randomUUID === 'function') { if (window.crypto && typeof window.crypto.randomUUID === 'function') {
@ -194,14 +129,12 @@ export const useCalendarStore = defineStore('calendar', {
eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate), eventData.colorId ?? this.selectEventColorId(eventData.startDate, eventData.endDate),
startTime: singleDay ? eventData.startTime || '09:00' : null, startTime: singleDay ? eventData.startTime || '09:00' : null,
durationMinutes: singleDay ? eventData.durationMinutes || 60 : null, durationMinutes: singleDay ? eventData.durationMinutes || 60 : null,
// Normalized repeat value: only 'weeks', 'months', or 'none'
repeat: ['weeks', 'months'].includes(eventData.repeat) ? eventData.repeat : 'none', repeat: ['weeks', 'months'].includes(eventData.repeat) ? eventData.repeat : 'none',
repeatInterval: eventData.repeatInterval || 1, repeatInterval: eventData.repeatInterval || 1,
repeatCount: eventData.repeatCount || 'unlimited', repeatCount: eventData.repeatCount || 'unlimited',
repeatWeekdays: eventData.repeatWeekdays, repeatWeekdays: eventData.repeatWeekdays,
isRepeating: eventData.repeat && eventData.repeat !== 'none', isRepeating: eventData.repeat && eventData.repeat !== 'none',
} }
this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate }) this.events.set(event.id, { ...event, isSpanning: event.startDate < event.endDate })
return event.id return event.id
}, },
@ -214,53 +147,42 @@ export const useCalendarStore = defineStore('calendar', {
const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0] const colorCounts = [0, 0, 0, 0, 0, 0, 0, 0]
const startDate = fromLocalString(startDateStr, DEFAULT_TZ) const startDate = fromLocalString(startDateStr, DEFAULT_TZ)
const endDate = fromLocalString(endDateStr, DEFAULT_TZ) const endDate = fromLocalString(endDateStr, DEFAULT_TZ)
// Count events whose ranges overlap at least one day in selected span
for (const ev of this.events.values()) { for (const ev of this.events.values()) {
const evStart = fromLocalString(ev.startDate) const evStart = fromLocalString(ev.startDate)
const evEnd = fromLocalString(ev.endDate) const evEnd = fromLocalString(ev.endDate)
if (evEnd < startDate || evStart > endDate) continue if (evEnd < startDate || evStart > endDate) continue
if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++ if (ev.colorId >= 0 && ev.colorId < 8) colorCounts[ev.colorId]++
} }
let minCount = colorCounts[0] let minCount = colorCounts[0]
let selectedColor = 0 let selectedColor = 0
for (let c = 1; c < 8; c++) {
for (let colorId = 1; colorId < 8; colorId++) { if (colorCounts[c] < minCount) {
if (colorCounts[colorId] < minCount) { minCount = colorCounts[c]
minCount = colorCounts[colorId] selectedColor = c
selectedColor = colorId
} }
} }
return selectedColor return selectedColor
}, },
deleteEvent(eventId) { deleteEvent(eventId) {
console.log('Deleting event', eventId)
this.events.delete(eventId) this.events.delete(eventId)
}, },
// Remove the first (base) occurrence of a repeating event by shifting anchor forward
deleteFirstOccurrence(baseId) { deleteFirstOccurrence(baseId) {
console.log('Deleting first occurrence', baseId)
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base) return if (!base) return
if (!base.isRepeating) { if (!base.isRepeating) {
// Simple (non-repeating) event: delete entirely
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
const numericCount = const numericCount =
base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10) base.repeatCount === 'unlimited' ? Infinity : parseInt(base.repeatCount, 10)
if (numericCount <= 1) { if (numericCount <= 1) {
// Only one occurrence (or invalid count) -> delete event
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
// Get the next occurrence start date (index 1)
const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ) const nextStartStr = getOccurrenceDate(base, 1, DEFAULT_TZ)
if (!nextStartStr) { if (!nextStartStr) {
// No next occurrence; remove event
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
@ -271,39 +193,29 @@ export const useCalendarStore = defineStore('calendar', {
addDays(fromLocalString(nextStartStr, DEFAULT_TZ), durationDays), addDays(fromLocalString(nextStartStr, DEFAULT_TZ), durationDays),
DEFAULT_TZ, DEFAULT_TZ,
) )
// Mutate existing event instead of delete+recreate so references remain stable
base.startDate = nextStartStr base.startDate = nextStartStr
base.endDate = newEndStr base.endDate = newEndStr
if (numericCount !== Infinity) { if (numericCount !== Infinity) base.repeatCount = String(Math.max(1, numericCount - 1))
base.repeatCount = String(Math.max(1, numericCount - 1))
}
this.events.set(baseId, { ...base, isSpanning: base.startDate < base.endDate }) 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) { deleteSingleOccurrence(ctx) {
console.log('DeletesingleOccurrence')
const { baseId, occurrenceIndex } = ctx || {} const { baseId, occurrenceIndex } = ctx || {}
if (occurrenceIndex === undefined || occurrenceIndex === null) return if (occurrenceIndex == null) return
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base) return if (!base) return
if (!base.isRepeating) { if (!base.isRepeating) {
// Single non-repeating event deletion
if (occurrenceIndex === 0) this.deleteEvent(baseId) if (occurrenceIndex === 0) this.deleteEvent(baseId)
return return
} }
if (occurrenceIndex === 0) { if (occurrenceIndex === 0) {
// Delegate to specialized first-occurrence deletion
this.deleteFirstOccurrence(baseId) this.deleteFirstOccurrence(baseId)
return return
} }
// Save copy before truncation for computing next occurrence date
const snapshot = { ...base } const snapshot = { ...base }
// Cap original series to occurrences before the deleted one
base.repeatCount = occurrenceIndex base.repeatCount = occurrenceIndex
const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ) const nextStartStr = getOccurrenceDate(snapshot, occurrenceIndex + 1, DEFAULT_TZ)
console.log('Deleting single', occurrenceIndex, nextStartStr) if (!nextStartStr) return
if (!nextStartStr) return // no continuation
const durationDays = Math.max( const durationDays = Math.max(
0, 0,
differenceInCalendarDays( differenceInCalendarDays(
@ -336,40 +248,30 @@ export const useCalendarStore = defineStore('calendar', {
const { baseId, occurrenceIndex } = ctx const { baseId, occurrenceIndex } = ctx
const base = this.getEventById(baseId) const base = this.getEventById(baseId)
if (!base || !base.isRepeating) return if (!base || !base.isRepeating) return
// Special case: if deleting from the base occurrence (index 0), delete the entire series
if (occurrenceIndex === 0) { if (occurrenceIndex === 0) {
this.deleteEvent(baseId) this.deleteEvent(baseId)
return return
} }
const keptTotal = occurrenceIndex this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
this._terminateRepeatSeriesAtIndex(baseId, keptTotal)
}, },
// 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)
if (!snapshot) return if (!snapshot) return
// Calculate current duration in days (inclusive)
const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ) const prevStart = fromLocalString(snapshot.startDate, DEFAULT_TZ)
const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ) const prevEnd = fromLocalString(snapshot.endDate, DEFAULT_TZ)
const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart)) const prevDurationDays = Math.max(0, differenceInCalendarDays(prevEnd, prevStart))
const newStart = fromLocalString(newStartStr, DEFAULT_TZ) const newStart = fromLocalString(newStartStr, DEFAULT_TZ)
const newEnd = fromLocalString(newEndStr, DEFAULT_TZ) const newEnd = fromLocalString(newEndStr, DEFAULT_TZ)
const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart)) const proposedDurationDays = Math.max(0, differenceInCalendarDays(newEnd, newStart))
let finalDurationDays = prevDurationDays let finalDurationDays = prevDurationDays
if (mode === 'resize-left' || mode === 'resize-right') { if (mode === 'resize-left' || mode === 'resize-right')
finalDurationDays = proposedDurationDays finalDurationDays = proposedDurationDays
}
snapshot.startDate = newStartStr snapshot.startDate = newStartStr
snapshot.endDate = toLocalString( snapshot.endDate = toLocalString(
addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays), addDays(fromLocalString(newStartStr, DEFAULT_TZ), finalDurationDays),
DEFAULT_TZ, DEFAULT_TZ,
) )
// Rotate weekly recurrence pattern when moving (not resizing) so selected weekdays track shift
if ( if (
mode === 'move' && mode === 'move' &&
snapshot.isRepeating && snapshot.isRepeating &&
@ -391,29 +293,20 @@ export const useCalendarStore = defineStore('calendar', {
snapshot.repeatWeekdays = rotated snapshot.repeatWeekdays = rotated
} }
} }
// Update the event directly this.events.set(eventId, { ...snapshot, isSpanning: snapshot.startDate < snapshot.endDate })
this.events.set(eventId, {
...snapshot,
startDate: snapshot.startDate,
endDate: snapshot.endDate,
isSpanning: snapshot.startDate < snapshot.endDate,
})
}, },
// Split a repeating series at a specific occurrence date and move that occurrence (and future) to new range
splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) { splitMoveVirtualOccurrence(baseId, occurrenceDateStr, newStartStr, newEndStr) {
const base = this.events.get(baseId) const base = this.events.get(baseId)
if (!base || !base.isRepeating) return if (!base || !base.isRepeating) return
const originalCountRaw = base.repeatCount const originalCountRaw = base.repeatCount
// spanDays not needed for splitting logic here post-refactor
const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ) const occurrenceDate = fromLocalString(occurrenceDateStr, DEFAULT_TZ)
const baseStart = fromLocalString(base.startDate, DEFAULT_TZ) const baseStart = fromLocalString(base.startDate, DEFAULT_TZ)
if (occurrenceDate <= baseStart) { if (occurrenceDate <= baseStart) {
// Moving the base itself: just move entire series
this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' }) this.setEventRange(baseId, newStartStr, newEndStr, { mode: 'move' })
return return
} }
let keptOccurrences = 0 // number of occurrences BEFORE the moved one let keptOccurrences = 0
if (base.repeat === 'weeks') { if (base.repeat === 'weeks') {
const interval = base.repeatInterval || 1 const interval = base.repeatInterval || 1
const pattern = base.repeatWeekdays || [] const pattern = base.repeatWeekdays || []
@ -435,15 +328,12 @@ export const useCalendarStore = defineStore('calendar', {
(occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 + (occurrenceDate.getFullYear() - baseStart.getFullYear()) * 12 +
(occurrenceDate.getMonth() - baseStart.getMonth()) (occurrenceDate.getMonth() - baseStart.getMonth())
const interval = base.repeatInterval || 1 const interval = base.repeatInterval || 1
if (diffMonths <= 0 || diffMonths % interval !== 0) return // invalid occurrence if (diffMonths <= 0 || diffMonths % interval !== 0) return
keptOccurrences = diffMonths // base is occurrence 0; we keep all before diffMonths keptOccurrences = diffMonths
} else { } else {
// Unsupported repeat type
return return
} }
// Truncate original series to keptOccurrences
this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences) this._terminateRepeatSeriesAtIndex(baseId, keptOccurrences)
// Compute remaining occurrences count
let remainingCount = 'unlimited' let remainingCount = 'unlimited'
if (originalCountRaw !== 'unlimited') { if (originalCountRaw !== 'unlimited') {
const total = parseInt(originalCountRaw, 10) const total = parseInt(originalCountRaw, 10)
@ -453,10 +343,8 @@ export const useCalendarStore = defineStore('calendar', {
remainingCount = String(rem) remainingCount = String(rem)
} }
} }
// Determine repeat-specific adjustments
let repeatWeekdays = base.repeatWeekdays let repeatWeekdays = base.repeatWeekdays
if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) { if (base.repeat === 'weeks' && Array.isArray(base.repeatWeekdays)) {
// Rotate pattern so that the moved occurrence weekday stays active relative to new anchor
const origWeekday = occurrenceDate.getDay() const origWeekday = occurrenceDate.getDay()
const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay() const newWeekday = fromLocalString(newStartStr, DEFAULT_TZ).getDay()
const shift = newWeekday - origWeekday const shift = newWeekday - origWeekday
@ -472,7 +360,6 @@ export const useCalendarStore = defineStore('calendar', {
repeatWeekdays = rotated repeatWeekdays = rotated
} }
} }
// Create continuation series starting at newStartStr
this.createEvent({ this.createEvent({
title: base.title, title: base.title,
startDate: newStartStr, startDate: newStartStr,
@ -485,15 +372,11 @@ export const useCalendarStore = defineStore('calendar', {
}) })
}, },
// Split a repeating series at a given occurrence index; returns new series id splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr) {
splitRepeatSeries(baseId, occurrenceIndex, newStartStr, newEndStr, _grabbedWeekday) {
const base = this.events.get(baseId) const base = this.events.get(baseId)
if (!base || !base.isRepeating) return null if (!base || !base.isRepeating) return null
// Capture original repeatCount BEFORE truncation
const originalCountRaw = base.repeatCount const originalCountRaw = base.repeatCount
// Truncate base to keep occurrences before split point (indices 0..occurrenceIndex-1)
this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex) this._terminateRepeatSeriesAtIndex(baseId, occurrenceIndex)
// Compute new series repeatCount (remaining occurrences starting at occurrenceIndex)
let newSeriesCount = 'unlimited' let newSeriesCount = 'unlimited'
if (originalCountRaw !== 'unlimited') { if (originalCountRaw !== 'unlimited') {
const originalNum = parseInt(originalCountRaw, 10) const originalNum = parseInt(originalCountRaw, 10)
@ -502,7 +385,7 @@ export const useCalendarStore = defineStore('calendar', {
newSeriesCount = String(Math.max(1, remaining)) newSeriesCount = String(Math.max(1, remaining))
} }
} }
const newId = this.createEvent({ return this.createEvent({
title: base.title, title: base.title,
startDate: newStartStr, startDate: newStartStr,
endDate: newEndStr, endDate: newEndStr,
@ -512,7 +395,6 @@ export const useCalendarStore = defineStore('calendar', {
repeatCount: newSeriesCount, repeatCount: newSeriesCount,
repeatWeekdays: base.repeatWeekdays, repeatWeekdays: base.repeatWeekdays,
}) })
return newId
}, },
_terminateRepeatSeriesAtIndex(baseId, index) { _terminateRepeatSeriesAtIndex(baseId, index) {
@ -525,15 +407,10 @@ export const useCalendarStore = defineStore('calendar', {
if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index)) if (!isNaN(rc)) ev.repeatCount = String(Math.min(rc, index))
} }
}, },
// _findEventInAnyList removed (direct map access)
// NOTE: legacy dynamic getEventById for synthetic occurrences removed.
}, },
persist: { persist: {
key: 'calendar-store', key: 'calendar-store',
storage: localStorage, storage: localStorage,
// Persist only events map, no dates indexing
paths: ['today', 'config', 'events'], paths: ['today', 'config', 'events'],
serializer: { serializer: {
serialize(value) { serialize(value) {