export default class calendarUtil { static addDays(date, days) { const d = new Date(date); d.setDate(d.getDate() + days); return d; } static addMonths(date, months) { const d = new Date(date); const day = d.getDate(); d.setMonth(d.getMonth() + months); // setMonth overflows short months (e.g. Jan 31 + 1 → Mar 3); clamp to last day of target month if (d.getDate() !== day) d.setDate(0); return d; } static rangesOverlap(startA, endA, startB, endB) { return startA < endB && endA > startB; } static getCalendarColor(calendars, calendarId) { return calendars.find(c => c.id === calendarId)?.color ?? "#888888"; } static getWeekNumber(date) { const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); } static generateTimeSlots({ startHour = 0, endHour = 24, stepMinutes = 60, use12Hour = true } = {}) { const slots = []; for (let minutes = startHour * 60; minutes <= endHour * 60; minutes += stepMinutes) { // inclusive endHour const hour24 = Math.floor(minutes / 60); const minute = minutes % 60; let label; if (use12Hour) { const suffix = (hour24 % 24) < 12 ? "am" : "pm"; const hour12 = hour24 % 12 || 12; label = minute === 0 ? `${hour12}${suffix}` : `${hour12}:${String(minute).padStart(2, "0")}${suffix}`; } else { label = `${String(hour24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; } slots.push({ hour24, minute, totalMinutes: minutes, label }); } return slots; } static isToday(date) { const now = new Date(); return ( now.getFullYear() === date.getFullYear() && now.getMonth() === date.getMonth() && now.getDate() === date.getDate() ); } static isSameDay(a, b) { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } static isSameWeek(a, b) { return calendarUtil.isSameDay(this.startOfWeek(a), this.startOfWeek(b)); } static isSameMonth(a, b) { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() ); } static startOfWeek(date, weekStartsOn = 0) { const d = new Date(date); d.setHours(0, 0, 0, 0); const day = d.getDay(); // 0=Sun, 1=Mon ... const diff = (day - weekStartsOn + 7) % 7; d.setDate(d.getDate() - diff); return d; } static startOfDay(date) { const d = new Date(date); d.setHours(0, 0, 0, 0); return d; } static endOfDay(date) { const d = new Date(date); d.setHours(24, 0, 0, 0); return d; } static toDateInput(date) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } static toTimeInput(date) { return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; } // Returns the effective end time for overlap checks, ensuring zero-duration // timed events (start === end) still register as occupying their start moment. static timedEnd(event) { return event.time_end > event.time_start ? event.time_end : new Date(event.time_start.getTime() + 1); } // Converts a date string + all_day flag to a UTC ISO string. // When all_day is true and isEnd is true, sets time to 23:59:59.999 so // rangesOverlap (strict endA > startB) catches same-day events. static toISO(str, allDay, isEnd = false) { if (!allDay) return new Date(str).toISOString() const d = new Date(str + "T00:00:00") if (isEnd) d.setHours(23, 59, 59, 999) return d.toISOString() } // Formats an event's time range as a human-readable string. static formatEventTime(event) { const start = new Date(event.time_start) const end = new Date(event.time_end) const dateFmt = { weekday: "short", month: "short", day: "numeric" } const timeFmt = { hour: "numeric", minute: "2-digit" } if (event.all_day) { const startStr = start.toLocaleDateString("en-US", dateFmt) if (calendarUtil.isSameDay(start, end)) return `All day · ${startStr}` return `All day · ${startStr} – ${end.toLocaleDateString("en-US", dateFmt)}` } return `${start.toLocaleDateString("en-US", dateFmt)} · ${start.toLocaleTimeString("en-US", timeFmt)} – ${end.toLocaleTimeString("en-US", timeFmt)}` } // Formats a Date to a short 12-hour time string, e.g. "3pm" or "3:30pm". static formatTimeShort(date) { const h = date.getHours() const m = date.getMinutes() const suffix = h < 12 ? "am" : "pm" const h12 = h % 12 || 12 return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, "0")}${suffix}` } // Returns a relative time string, e.g. "3h ago", "2d ago", "just now". static timeAgo(dateStr) { const seconds = Math.floor((Date.now() - new Date(dateStr)) / 1000) if (seconds < 60) return "just now" const minutes = Math.floor(seconds / 60) if (minutes < 60) return `${minutes}m ago` const hours = Math.floor(minutes / 60) if (hours < 24) return `${hours}h ago` const days = Math.floor(hours / 24) if (days < 7) return `${days}d ago` const weeks = Math.floor(days / 7) if (weeks < 5) return `${weeks}w ago` const months = Math.floor(days / 30) if (months < 12) return `${months}mo ago` return `${Math.floor(days / 365)}y ago` } // Returns a flat array of 42 Date objects covering the 6-week grid for the // given month, aligned to weekStartsOn (0 = Sun, 1 = Mon). static buildMonthGrid(date, weekStartsOn = 0) { const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1) const gridStart = calendarUtil.startOfWeek(firstOfMonth, weekStartsOn) return Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i)) } // Generates every occurrence Date of a recurring template within [rangeStart, rangeEnd]. static generateOccurrenceDates(template, rangeStart, rangeEnd) { const rule = template.recurrence; if (!rule) return []; const duration = template.time_end.getTime() - template.time_start.getTime(); const seriesStart = new Date(template.time_start); const endDate = rule.end_date ? new Date(rule.end_date) : null; const maxCount = rule.count ?? Infinity; const interval = rule.interval ?? 1; const dates = []; let count = 0; // Generous overlap: use at least 1 day of coverage so zero-duration all-day events aren't missed. const inRange = (occ) => { const occEnd = new Date(occ.getTime() + Math.max(duration, 86400000)); return occ <= rangeEnd && occEnd >= rangeStart; }; if (rule.frequency === 'weekly' && rule.days_of_week?.length > 0) { const sortedDays = [...rule.days_of_week].sort((a, b) => a - b); // Anchor on the Sunday of the week containing seriesStart const sun = new Date(seriesStart); sun.setDate(sun.getDate() - sun.getDay()); sun.setHours(seriesStart.getHours(), seriesStart.getMinutes(), seriesStart.getSeconds(), 0); weekLoop: for (let weekOffset = 0; ; weekOffset += interval) { const weekSun = new Date(sun); weekSun.setDate(sun.getDate() + weekOffset * 7); if (weekSun > rangeEnd) break; for (const dayIdx of sortedDays) { const occ = new Date(weekSun); occ.setDate(weekSun.getDate() + dayIdx); if (occ < seriesStart) continue; if (endDate && occ >= endDate) break weekLoop; if (count >= maxCount) break weekLoop; count++; if (occ > rangeEnd) break weekLoop; if (inRange(occ)) dates.push(occ); } } } else { const advance = (d) => { const next = new Date(d); const day = next.getDate(); switch (rule.frequency) { case 'daily': next.setDate(next.getDate() + interval); break; case 'weekly': next.setDate(next.getDate() + interval * 7); break; case 'monthly': next.setMonth(next.getMonth() + interval); break; case 'yearly': next.setFullYear(next.getFullYear() + interval); break; } // Clamp month/year overflow (e.g. Jan 31 + 1 month → Mar 3, or Feb 29 + 1 year → Mar 1) if ((rule.frequency === 'monthly' || rule.frequency === 'yearly') && next.getDate() !== day) { next.setDate(0); } return next; }; let current = new Date(seriesStart); while (current <= rangeEnd) { if (endDate && current >= endDate) break; if (count >= maxCount) break; count++; if (inRange(current)) dates.push(new Date(current)); current = advance(current); } } return dates; } // Expands recurring template events into concrete occurrences within [rangeStart, rangeEnd]. // Override rows replace their corresponding virtual occurrence; cancelled ones are skipped. // Non-recurring events pass through unchanged. static expandRecurringEvents(events, rangeStart, rangeEnd) { const templates = events.filter(e => e.recurrence_id && !e.recurrence_parent_id); const overrides = events.filter(e => !!e.recurrence_parent_id); const regular = events.filter(e => !e.recurrence_id && !e.recurrence_parent_id); const overrideMap = {}; overrides.forEach(ov => { const pid = ov.recurrence_parent_id; if (!overrideMap[pid]) overrideMap[pid] = {}; const key = new Date(ov.recurrence_exception_date).toDateString(); overrideMap[pid][key] = ov; }); const result = [...regular]; templates.forEach(template => { const templateOverrides = overrideMap[template.id] || {}; const duration = template.time_end.getTime() - template.time_start.getTime(); calendarUtil.generateOccurrenceDates(template, rangeStart, rangeEnd).forEach(occDate => { const override = templateOverrides[occDate.toDateString()]; if (override) { if (!override.is_cancelled) result.push(override); } else { result.push({ ...template, time_start: occDate, time_end: new Date(occDate.getTime() + duration), _isOccurrence: true, _occurrenceDate: occDate, _templateStart: new Date(template.time_start), _templateEnd: new Date(template.time_end), }); } }); }); return result; } }