/** * Appointment Calendar / Schedule Module * Handles day, week and month calendar views for appointments, jobs, and maintenance */ const appointmentCalendar = { currentView: 'week', currentDate: new Date(), events: [], unscheduledJobs: [], sidebarCollapsed: false, // ────────────────────────────────────────────────────────────────────────── // Init // ────────────────────────────────────────────────────────────────────────── init: function(view = 'week', date = new Date()) { this.currentView = view; this.currentDate = new Date(date); this.updateViewButtons(); this.attachEventListeners(); this.loadAndRender(); this.loadUnscheduledJobs(); this.setupSidebarToggle(); this.setupSidebarDropZone(); }, attachEventListeners: function() { document.getElementById('btnPrevious').addEventListener('click', () => this.goToPrevious()); document.getElementById('btnNext').addEventListener('click', () => this.goToNext()); document.getElementById('btnToday').addEventListener('click', () => this.goToToday()); document.getElementById('btnDayView').addEventListener('click', () => this.switchView('day')); document.getElementById('btnWeekView').addEventListener('click', () => this.switchView('week')); document.getElementById('btnMonthView').addEventListener('click', () => this.switchView('month')); let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => this.renderCalendar(), 250); }); }, // ────────────────────────────────────────────────────────────────────────── // Load & Render // ────────────────────────────────────────────────────────────────────────── loadAndRender: async function() { const { start, end } = this.getDateRange(); await this.loadEvents(start, end); this.renderCalendar(); this.updateCurrentDateDisplay(); this.updateURL(); }, loadEvents: async function(startDate, endDate) { try { const url = `/Appointments/GetCalendarEvents?start=${startDate.toISOString()}&end=${endDate.toISOString()}`; const response = await fetch(url); if (!response.ok) throw new Error('Failed to load events'); this.events = await response.json(); } catch (error) { console.error('Error loading events:', error); this.events = []; } }, renderCalendar: function() { const container = document.getElementById('calendarContainer'); if (this.currentView === 'day') { container.innerHTML = this.renderDayView(); } else if (this.currentView === 'week') { container.innerHTML = this.renderWeekView(); } else { container.innerHTML = this.renderMonthView(); } this.attachEventClickHandlers(); this.attachDropHandlers(); }, // ────────────────────────────────────────────────────────────────────────── // Unscheduled Jobs Sidebar // ────────────────────────────────────────────────────────────────────────── loadUnscheduledJobs: async function() { try { const resp = await fetch('/Appointments/GetUnscheduledJobs'); this.unscheduledJobs = await resp.json(); } catch (e) { this.unscheduledJobs = []; } this.renderUnscheduledPanel(); }, renderUnscheduledPanel: function() { const panel = document.getElementById('unscheduledJobsPanel'); const countEl = document.getElementById('unscheduledCount'); if (!panel) return; const count = this.unscheduledJobs.length; if (countEl) countEl.textContent = count > 0 ? `${count} job${count === 1 ? '' : 's'}` : ''; if (count === 0) { panel.innerHTML = '
All jobs scheduled
'; return; } let html = ''; this.unscheduledJobs.forEach(job => { let dueLine = ''; if (job.dueDate) { const cls = job.isOverdue ? 'text-danger fw-semibold' : 'text-muted'; dueLine = `
${this.formatDateShort(job.dueDate)}${job.isOverdue ? ' ⚠' : ''}
`; } const previewData = JSON.stringify({ jobNumber: job.jobNumber, customerName: job.customerName, statusName: job.statusName, color: job.color, dueDate: job.dueDate, isOverdue: job.isOverdue, quotedPrice: job.quotedPrice, specialInstructions: job.specialInstructions, items: job.items || [], itemCount: job.itemCount || 0 }); html += `
${this.escapeHtml(job.jobNumber)}
${this.escapeHtml(job.customerName)}
${this.escapeHtml(job.statusName)}
${dueLine}
`; }); panel.innerHTML = html; this.attachSidebarDragHandlers(); this.attachHoverPreviewHandlers(); }, attachHoverPreviewHandlers: function() { const popout = document.getElementById('sjPreviewCard'); if (!popout) return; document.querySelectorAll('.sj-card').forEach(card => { card.addEventListener('mouseenter', (e) => { let data; try { data = JSON.parse(decodeURIComponent(card.dataset.preview)); } catch { return; } popout.innerHTML = this.buildPreviewHtml(data); popout.style.display = 'block'; this.positionPreview(e); }); card.addEventListener('mousemove', (e) => this.positionPreview(e)); card.addEventListener('mouseleave', () => { popout.style.display = 'none'; }); card.addEventListener('dragstart', () => { popout.style.display = 'none'; }); }); }, positionPreview: function(e) { const popout = document.getElementById('sjPreviewCard'); if (!popout) return; const offset = 14; const vw = window.innerWidth, vh = window.innerHeight; const pw = popout.offsetWidth || 240, ph = popout.offsetHeight || 160; let left = e.clientX + offset; let top = e.clientY + offset; if (left + pw > vw - 8) left = e.clientX - pw - offset; if (top + ph > vh - 8) top = e.clientY - ph - offset; popout.style.left = left + 'px'; popout.style.top = top + 'px'; }, buildPreviewHtml: function(d) { const dueLine = d.dueDate ? `
Due ${this.formatDateShort(d.dueDate)}${d.isOverdue ? ' — Overdue' : ''}
` : ''; const priceLine = d.quotedPrice > 0 ? `
Quoted $${Number(d.quotedPrice).toFixed(2)}
` : ''; const notesLine = d.specialInstructions ? `
${this.escapeHtml(d.specialInstructions)}
` : ''; let itemsHtml = ''; if (d.items && d.items.length > 0) { itemsHtml = '
'; d.items.forEach(item => { const colorDot = item.colorName ? `${this.escapeHtml(item.colorName)}` : ''; itemsHtml += `
×${item.quantity}${this.escapeHtml(item.description)}${colorDot ? colorDot : ''}
`; }); if (d.itemCount > d.items.length) { itemsHtml += `
+ ${d.itemCount - d.items.length} more item${d.itemCount - d.items.length > 1 ? 's' : ''}
`; } itemsHtml += '
'; } return `
${this.escapeHtml(d.statusName)}
${this.escapeHtml(d.jobNumber)}
${this.escapeHtml(d.customerName)}
${dueLine}${priceLine}${notesLine} ${itemsHtml}
`; }, attachSidebarDragHandlers: function() { document.querySelectorAll('.sj-card').forEach(card => { card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'job', jobId: card.dataset.jobId, fromDate: null })); e.dataTransfer.effectAllowed = 'move'; card.classList.add('sj-dragging'); }); card.addEventListener('dragend', () => card.classList.remove('sj-dragging')); }); }, setupSidebarDropZone: function() { const panel = document.getElementById('unscheduledJobsPanel'); if (!panel) return; const sidebar = document.getElementById('unscheduledSidebar'); if (!sidebar) return; [panel, sidebar].forEach(el => { el.addEventListener('dragover', (e) => { e.preventDefault(); sidebar.classList.add('sidebar-drag-over'); }); el.addEventListener('dragleave', () => sidebar.classList.remove('sidebar-drag-over')); el.addEventListener('drop', (e) => { e.preventDefault(); sidebar.classList.remove('sidebar-drag-over'); let data; try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; } if (data.type === 'job' && data.fromDate) { this.scheduleJob(data.jobId, null); } }); }); }, setupSidebarToggle: function() { const btn = document.getElementById('btnCollapseSidebar'); if (!btn) return; btn.addEventListener('click', () => { this.sidebarCollapsed = !this.sidebarCollapsed; const sidebar = document.getElementById('unscheduledSidebar'); const chevron = document.getElementById('sidebarChevron'); const body = document.getElementById('unscheduledJobsPanel'); const countEl = document.getElementById('unscheduledCount'); const titleEl = document.getElementById('sidebarTitleText'); if (this.sidebarCollapsed) { sidebar.classList.add('sidebar-collapsed'); if (chevron) { chevron.classList.remove('bi-chevron-left'); chevron.classList.add('bi-chevron-right'); } if (body) body.style.display = 'none'; if (countEl) countEl.style.display = 'none'; if (titleEl) titleEl.style.display = 'none'; } else { sidebar.classList.remove('sidebar-collapsed'); if (chevron) { chevron.classList.remove('bi-chevron-right'); chevron.classList.add('bi-chevron-left'); } if (body) body.style.display = ''; if (countEl) countEl.style.display = ''; if (titleEl) titleEl.style.display = ''; } }); }, // ────────────────────────────────────────────────────────────────────────── // Drag & Drop — Drop Handlers // ────────────────────────────────────────────────────────────────────────── attachDropHandlers: function() { // Week view: day header columns document.querySelectorAll('.calendar-day-header[data-date]').forEach(cell => { this.makeDayDropTarget(cell); }); // Month view: month cells document.querySelectorAll('.calendar-month-cell[data-date]').forEach(cell => { this.makeDayDropTarget(cell); }); // Day view: single day header const dayHeader = document.querySelector('.calendar-day-header-single[data-date]'); if (dayHeader) this.makeDayDropTarget(dayHeader); }, makeDayDropTarget: function(cell) { cell.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); cell.classList.add('calendar-drag-over'); }); cell.addEventListener('dragleave', (e) => { if (!cell.contains(e.relatedTarget)) { cell.classList.remove('calendar-drag-over'); } }); cell.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); cell.classList.remove('calendar-drag-over'); let data; try { data = JSON.parse(e.dataTransfer.getData('text/plain')); } catch { return; } if (data.type !== 'job') return; const dateStr = cell.dataset.date; if (!dateStr) return; this.scheduleJob(data.jobId, new Date(dateStr)); }); }, scheduleJob: async function(jobId, date) { const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value; const params = new URLSearchParams({ id: jobId }); if (date) params.append('date', date.toISOString().split('T')[0]); if (token) params.append('__RequestVerificationToken', token); try { const resp = await fetch('/Appointments/ScheduleJob', { method: 'POST', body: params }); const result = await resp.json(); if (result.success) { await this.loadAndRender(); await this.loadUnscheduledJobs(); if (result.removedFromBatch) { this.showToast(`Job scheduled. Removed from oven batch ${this.escapeHtml(result.removedFromBatch)}.`, 'warning'); } else if (date) { this.showToast('Job scheduled.', 'success'); } else { this.showToast('Job unscheduled.', 'info'); } } else { this.showToast(result.message || 'Failed to update job.', 'danger'); } } catch (e) { this.showToast('Failed to update job.', 'danger'); } }, showToast: function(message, type = 'success') { const container = document.getElementById('scheduleToastContainer'); if (!container) return; const id = 'toast-' + Date.now(); const bgMap = { success: 'bg-success', warning: 'bg-warning text-dark', info: 'bg-info text-dark', danger: 'bg-danger' }; const cls = bgMap[type] || 'bg-secondary'; container.insertAdjacentHTML('beforeend', ` `); const el = document.getElementById(id); if (el && window.bootstrap) new bootstrap.Toast(el, { delay: 4000 }).show(); }, // ────────────────────────────────────────────────────────────────────────── // Day View // ────────────────────────────────────────────────────────────────────────── renderDayView: function() { const day = this.currentDate; const isToday = this.isToday(day); const allDayEvents = this.getAllDayEventsForDay(day); const totalEvents = this.getEventsForDay(day).length; let html = '
'; html += '
'; html += `
`; html += '
'; html += `
${this.getDayName(day, 'long')}
`; html += `
${day.getDate()}
`; html += `
${this.getMonthName(day)} ${day.getFullYear()}
`; if (totalEvents > 0) { html += `
${totalEvents} event${totalEvents === 1 ? '' : 's'}
`; } html += '
'; if (allDayEvents.length > 0) { html += '
'; allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header')); html += '
'; } html += '
'; html += '
'; for (let hour = 6; hour < 21; hour++) { const hourEvents = this.getEventsForDayHour(day, hour); html += `
`; html += `
${this.formatHour(hour)}
`; html += '
'; hourEvents.forEach(event => html += this.renderEventCard(event, 'day')); html += '
'; } html += '
'; return html; }, // ────────────────────────────────────────────────────────────────────────── // Week View // ────────────────────────────────────────────────────────────────────────── renderWeekView: function() { const { start } = this.getDateRange(); const days = []; for (let i = 0; i < 7; i++) { const day = new Date(start); day.setDate(start.getDate() + i); days.push(day); } let html = '
'; html += '
'; html += '
'; days.forEach(day => { const isToday = this.isToday(day); const allDayEvents = this.getAllDayEventsForDay(day); html += `
`; html += '
'; html += `
${this.getDayName(day, 'short')}
`; html += `
${day.getDate()}
`; html += '
'; if (allDayEvents.length > 0) { html += '
'; allDayEvents.forEach(event => html += this.renderEventCard(event, 'week-header')); html += '
'; } html += '
'; }); html += '
'; html += '
'; for (let hour = 6; hour < 20; hour++) { html += '
'; html += `
${this.formatHour(hour)}
`; days.forEach(day => { const dayEvents = this.getEventsForDayHour(day, hour); html += `
`; dayEvents.forEach(event => html += this.renderEventCard(event, 'week')); html += '
'; }); html += '
'; } html += '
'; return html; }, // ────────────────────────────────────────────────────────────────────────── // Month View // ────────────────────────────────────────────────────────────────────────── renderMonthView: function() { const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1); const calendarStart = new Date(firstDay); calendarStart.setDate(calendarStart.getDate() - ((calendarStart.getDay() + 6) % 7)); let html = '
'; html += '
'; ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].forEach(name => { html += `
${name}
`; }); html += '
'; html += '
'; let currentDay = new Date(calendarStart); for (let week = 0; week < 6; week++) { html += '
'; for (let day = 0; day < 7; day++) { const isCurrentMonth = currentDay.getMonth() === this.currentDate.getMonth(); const isToday = this.isToday(currentDay); const allEvents = this.getEventsForDay(currentDay); const dayIso = currentDay.toISOString(); const allDayEvents = allEvents.filter(e => e.allDay); const timedEvents = allEvents.filter(e => !e.allDay); html += `
`; html += `
${currentDay.getDate()}
`; html += '
'; allDayEvents.forEach(event => html += this.renderEventCard(event, 'month')); const maxEvents = 3; const remainingSlots = maxEvents - allDayEvents.length; timedEvents.slice(0, remainingSlots).forEach(event => html += this.renderEventCard(event, 'month')); const totalEvents = allEvents.length; if (totalEvents > maxEvents) { html += `
+${totalEvents - maxEvents} more
`; } html += '
'; currentDay.setDate(currentDay.getDate() + 1); } html += '
'; } html += '
'; return html; }, // ────────────────────────────────────────────────────────────────────────── // Event Card Rendering // ────────────────────────────────────────────────────────────────────────── renderEventCard: function(event, viewType) { const colorClass = this.getColorClass(event.backgroundColor); const time = event.allDay ? 'All Day' : new Date(event.start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); const eventType = event.eventType || 'Appointment'; const allDayClass = event.allDay ? 'all-day-event' : ''; const isJob = eventType === 'Job'; const dragAttr = isJob ? `draggable="true" data-job-id="${event.id}"` : ''; const fallbackAttr = isJob && event.isFallbackDate ? 'data-fallback="true"' : ''; const fallbackStyle = isJob && event.isFallbackDate ? 'opacity:0.75;border-style:dashed;' : ''; const jobIcon = isJob ? '' : ''; const fallbackIcon = isJob && event.isFallbackDate ? ' ' : ''; if (viewType === 'week-header') { return `
${jobIcon}${this.escapeHtml(isJob ? (event.jobNumber || event.title) : event.title)}${fallbackIcon}
`; } else if (viewType === 'day') { const locationHtml = event.location ? `
${this.escapeHtml(event.location)}
` : ''; return `
${jobIcon}${time}
${this.escapeHtml(event.title)}
${this.escapeHtml(event.customerName || '')}
${locationHtml}
`; } else if (viewType === 'week') { return `
${jobIcon}${time}
${this.escapeHtml(event.title)}
${this.escapeHtml(event.customerName)}
`; } else { // Month view return `
${jobIcon}${isJob ? this.escapeHtml(event.jobNumber || event.title) : (time + ' ' + this.escapeHtml(event.title))}
`; } }, // ────────────────────────────────────────────────────────────────────────── // Event Helpers // ────────────────────────────────────────────────────────────────────────── getEventsForDay: function(day) { const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999); return this.events.filter(event => { const eventStart = new Date(event.start); return eventStart >= dayStart && eventStart <= dayEnd; }); }, getAllDayEventsForDay: function(day) { const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0); const dayEnd = new Date(day); dayEnd.setHours(23, 59, 59, 999); return this.events.filter(event => { if (!event.allDay) return false; const eventStart = new Date(event.start); return eventStart >= dayStart && eventStart <= dayEnd; }); }, getEventsForDayHour: function(day, hour) { const hourStart = new Date(day); hourStart.setHours(hour, 0, 0, 0); const hourEnd = new Date(day); hourEnd.setHours(hour, 59, 59, 999); return this.events.filter(event => { if (event.allDay) return false; const eventStart = new Date(event.start); return eventStart >= hourStart && eventStart <= hourEnd; }); }, // ────────────────────────────────────────────────────────────────────────── // Click Handlers // ────────────────────────────────────────────────────────────────────────── attachEventClickHandlers: function() { document.querySelectorAll('.calendar-event, .calendar-event-month, .calendar-event-header').forEach(el => { el.addEventListener('click', (e) => { e.stopPropagation(); const eventId = el.dataset.eventId; const eventType = el.dataset.eventType || 'Appointment'; if (eventType === 'Maintenance') { window.location.href = `/Maintenance/Details/${eventId}`; } else if (eventType === 'Job') { window.location.href = `/Jobs/Details/${eventId}`; } else { window.location.href = `/Appointments/Details/${eventId}`; } }); }); document.querySelectorAll('.day-drill').forEach(el => { el.addEventListener('click', (e) => { e.stopPropagation(); const date = new Date(el.dataset.dayDate); this.switchToDay(date); }); }); // Job event drag handlers (calendar → calendar reschedule) document.querySelectorAll('[data-event-type="Job"]').forEach(el => { el.setAttribute('draggable', 'true'); el.addEventListener('dragstart', (e) => { e.stopPropagation(); const cell = el.closest('[data-date]'); e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'job', jobId: el.dataset.eventId, fromDate: cell?.dataset.date || null })); e.dataTransfer.effectAllowed = 'move'; el.classList.add('event-dragging'); }); el.addEventListener('dragend', () => el.classList.remove('event-dragging')); }); }, // ────────────────────────────────────────────────────────────────────────── // Date Ranges & Navigation // ────────────────────────────────────────────────────────────────────────── getDateRange: function() { if (this.currentView === 'day') return this.getDayRange(this.currentDate); if (this.currentView === 'week') return this.getWeekRange(this.currentDate); return this.getMonthRange(this.currentDate); }, getDayRange: function(date) { const start = new Date(date); start.setHours(0, 0, 0, 0); const end = new Date(date); end.setHours(23, 59, 59, 999); return { start, end }; }, getWeekRange: function(date) { const start = new Date(date); const day = start.getDay(); const diff = start.getDate() - day + (day === 0 ? -6 : 1); start.setDate(diff); start.setHours(0, 0, 0, 0); const end = new Date(start); end.setDate(start.getDate() + 7); end.setHours(23, 59, 59, 999); return { start, end }; }, getMonthRange: function(date) { const start = new Date(date.getFullYear(), date.getMonth(), 1); start.setHours(0, 0, 0, 0); const end = new Date(date.getFullYear(), date.getMonth() + 1, 0); end.setHours(23, 59, 59, 999); return { start, end }; }, goToPrevious: function() { if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() - 1); else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() - 7); else this.currentDate.setMonth(this.currentDate.getMonth() - 1); this.loadAndRender(); }, goToNext: function() { if (this.currentView === 'day') this.currentDate.setDate(this.currentDate.getDate() + 1); else if (this.currentView === 'week') this.currentDate.setDate(this.currentDate.getDate() + 7); else this.currentDate.setMonth(this.currentDate.getMonth() + 1); this.loadAndRender(); }, goToToday: function() { this.currentDate = new Date(); this.loadAndRender(); }, switchView: function(view) { this.currentView = view; this.updateViewButtons(); this.loadAndRender(); }, switchToDay: function(date) { this.currentDate = new Date(date); this.currentView = 'day'; this.updateViewButtons(); this.loadAndRender(); }, // ────────────────────────────────────────────────────────────────────────── // UI State // ────────────────────────────────────────────────────────────────────────── updateViewButtons: function() { document.getElementById('btnDayView').classList.toggle('active', this.currentView === 'day'); document.getElementById('btnWeekView').classList.toggle('active', this.currentView === 'week'); document.getElementById('btnMonthView').classList.toggle('active', this.currentView === 'month'); }, updateCurrentDateDisplay: function() { const display = document.getElementById('currentDateDisplay'); if (this.currentView === 'day') { display.textContent = `${this.getDayName(this.currentDate, 'long')}, ${this.getMonthName(this.currentDate)} ${this.currentDate.getDate()}, ${this.currentDate.getFullYear()}`; } else if (this.currentView === 'week') { const { start, end } = this.getWeekRange(this.currentDate); const endDisplay = new Date(end); endDisplay.setDate(endDisplay.getDate() - 1); if (start.getMonth() === endDisplay.getMonth()) { display.textContent = `${this.getMonthName(start)} ${start.getDate()}\u2013${endDisplay.getDate()}, ${start.getFullYear()}`; } else { display.textContent = `${this.getMonthName(start)} ${start.getDate()} \u2013 ${this.getMonthName(endDisplay)} ${endDisplay.getDate()}, ${start.getFullYear()}`; } } else { display.textContent = `${this.getMonthName(this.currentDate)} ${this.currentDate.getFullYear()}`; } }, updateURL: function() { const url = new URL(window.location); url.searchParams.set('view', this.currentView); url.searchParams.set('date', this.currentDate.toISOString().split('T')[0]); window.history.replaceState({}, '', url); }, // ────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────── isToday: function(date) { const today = new Date(); return date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear(); }, getDayName: function(date, format = 'long') { return date.toLocaleDateString('en-US', { weekday: format }); }, getMonthName: function(date) { return date.toLocaleDateString('en-US', { month: 'long' }); }, formatHour: function(hour) { const period = hour >= 12 ? 'PM' : 'AM'; const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour); return `${displayHour} ${period}`; }, formatDateShort: function(dateStr) { const d = new Date(dateStr + 'T00:00:00'); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }, getColorClass: function(bgColor) { if (bgColor && bgColor.startsWith('#')) { const colorMap = { '#6f42c1': 'calendar-event-purple', '#198754': 'calendar-event-green', '#0d6efd': 'calendar-event-blue', '#fd7e14': 'calendar-event-orange', '#d63384': 'calendar-event-pink', '#0dcaf0': 'calendar-event-cyan', '#20c997': 'calendar-event-teal', '#6610f2': 'calendar-event-indigo', '#84cc16': 'calendar-event-lime', '#795548': 'calendar-event-brown', '#dc3545': 'calendar-event-red', '#ffc107': 'calendar-event-yellow', '#6c757d': 'calendar-event-gray', '#adb5bd': 'calendar-event-gray', '#212529': 'calendar-event-dark' }; return colorMap[bgColor] || 'calendar-event-blue'; } return bgColor ? `calendar-event-${bgColor}` : 'calendar-event-blue'; }, escapeHtml: function(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } }; window.appointmentCalendar = appointmentCalendar; window.loadCalendarEvents = () => appointmentCalendar.loadAndRender();