/** * GoingsOn - Events Calendar Module * Month grid and week grid views for events. */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; let currentMonthDate = new Date(); let currentWeekDate = new Date(); let monthEvents = []; let weekEvents = []; let weekSwipeCleanup = null; function isMobileView() { // Route through the central UI-mode helper. The previous 600px // threshold was arbitrary and disagreed with the 768px breakpoint // used elsewhere; mode-based switching is the canonical signal now. return !!GoingsOn.viewport?.isMobile(); } // ============ Date Helpers ============ function getMonday(date) { const d = new Date(date); const day = d.getDay(); const diff = (day + 6) % 7; d.setDate(d.getDate() - diff); d.setHours(0, 0, 0, 0); return d; } function toDateKey(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function groupByDate(events) { const map = new Map(); for (const e of events) { const d = new Date(e.startTimeEpoch || new Date(e.startTime).getTime()); const key = toDateKey(d); if (!map.has(key)) map.set(key, []); map.get(key).push(e); } return map; } function truncate(str, len) { if (!str || str.length <= len) return str || ''; return str.substring(0, len - 1) + '\u2026'; } function formatMonthLabel(date) { return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); } function formatWeekLabel(monday) { const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); const opts = { month: 'short', day: 'numeric' }; const start = monday.toLocaleDateString('en-US', opts); const end = sunday.toLocaleDateString('en-US', { ...opts, year: 'numeric' }); return `${start} \u2013 ${end}`; } // ============ Month View ============ async function loadMonth() { const first = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), 1); const startOffset = (first.getDay() + 6) % 7; const gridStart = new Date(first); gridStart.setDate(first.getDate() - startOffset); const lastDay = new Date(first.getFullYear(), first.getMonth() + 1, 0); const gridEnd = new Date(lastDay); const endOffset = (7 - ((lastDay.getDay() + 6) % 7 + 1)) % 7; gridEnd.setDate(lastDay.getDate() + endOffset + 1); try { monthEvents = await GoingsOn.api.events.listBetween( gridStart.toISOString(), gridEnd.toISOString() ); } catch (err) { console.error('Failed to load month events:', err); monthEvents = []; } renderMonthGrid(first, gridStart, gridEnd); const label = document.getElementById('month-calendar-label'); if (label) label.textContent = formatMonthLabel(first); } function renderMonthGrid(firstOfMonth, gridStart, gridEnd) { const container = document.getElementById('month-calendar-grid'); if (!container) return; const eventsByDate = groupByDate(monthEvents); const today = toDateKey(new Date()); const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; let html = '
'; html += '
'; for (const d of dayHeaders) { html += `
${d}
`; } html += '
'; const cursor = new Date(gridStart); while (cursor < gridEnd) { const dateKey = toDateKey(cursor); const isCurrentMonth = cursor.getMonth() === firstOfMonth.getMonth(); const isToday = dateKey === today; const dayEvents = eventsByDate.get(dateKey) || []; const classes = ['cal-month-cell']; if (!isCurrentMonth) classes.push('other-month'); if (isToday) classes.push('today'); html += `
`; html += `
${cursor.getDate()}
`; const maxShow = 3; dayEvents.slice(0, maxShow).forEach(e => { const blockClass = e.blockType ? `block-${e.blockType}` : ''; html += `
${esc(truncate(e.title, 18))}
`; }); if (dayEvents.length > maxShow) { html += `
+${dayEvents.length - maxShow} more
`; } html += '
'; cursor.setDate(cursor.getDate() + 1); } html += '
'; container.innerHTML = html; // Hide day detail when month changes const detail = document.getElementById('month-day-detail'); if (detail) detail.classList.add('hidden'); } function toggleDayDetail(dateKey) { const detail = document.getElementById('month-day-detail'); if (!detail) return; if (detail.dataset.date === dateKey && !detail.classList.contains('hidden')) { detail.classList.add('hidden'); return; } const eventsByDate = groupByDate(monthEvents); const dayEvents = eventsByDate.get(dateKey) || []; const dateObj = new Date(dateKey + 'T12:00:00'); const dayLabel = dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' }); let html = `

${esc(dayLabel)}

`; if (dayEvents.length === 0) { html += '

No events this day.

'; } else { dayEvents.forEach(e => { const blockClass = e.blockType ? `block-${e.blockType}` : ''; html += `
${esc(e.timeFormatted)} ${esc(e.title)} ${e.location ? `${esc(e.location)}` : ''}
`; }); } detail.dataset.date = dateKey; detail.innerHTML = html; detail.classList.remove('hidden'); } function prevMonth() { currentMonthDate.setMonth(currentMonthDate.getMonth() - 1); loadMonth(); } function nextMonth() { currentMonthDate.setMonth(currentMonthDate.getMonth() + 1); loadMonth(); } function goToThisMonth() { currentMonthDate = new Date(); loadMonth(); } // ============ Week View ============ const SLOT_HEIGHT = 12; const HOURS_START = 6; const HOURS_END = 22; async function loadWeek() { const monday = getMonday(currentWeekDate); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 7); try { weekEvents = await GoingsOn.api.events.listBetween( monday.toISOString(), sunday.toISOString() ); } catch (err) { console.error('Failed to load week events:', err); weekEvents = []; } renderWeekGrid(monday); const label = document.getElementById('week-calendar-label'); if (label) { label.textContent = isMobileView() ? currentWeekDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : formatWeekLabel(monday); } } function isAllDayEvent(e) { if (!e.endTime) return false; const startEpoch = e.startTimeEpoch || new Date(e.startTime).getTime(); const endEpoch = e.endTimeEpoch || new Date(e.endTime).getTime(); return (endEpoch - startEpoch) >= 23 * 3600 * 1000; } function renderWeekGrid(monday) { const container = document.getElementById('week-calendar-grid'); if (!container) return; if (isMobileView()) { renderMobileDay(container); return; } const eventsByDate = groupByDate(weekEvents); const today = toDateKey(new Date()); const totalSlots = (HOURS_END - HOURS_START) * 4; const gridHeight = totalSlots * SLOT_HEIGHT; let html = '
'; // Header row html += '
'; const cursor = new Date(monday); for (let d = 0; d < 7; d++) { const dateKey = toDateKey(cursor); const dayName = cursor.toLocaleDateString('en-US', { weekday: 'short' }); html += `
${dayName} ${cursor.getDate()}
`; cursor.setDate(cursor.getDate() + 1); } html += '
'; // All-day row html += '
All Day
'; const adCursor = new Date(monday); for (let d = 0; d < 7; d++) { const dateKey = toDateKey(adCursor); const dayEvts = (eventsByDate.get(dateKey) || []).filter(isAllDayEvent); html += '
'; dayEvts.forEach(e => { const blockClass = e.blockType ? `block-${e.blockType}` : ''; html += `
${esc(truncate(e.title, 14))}
`; }); html += '
'; adCursor.setDate(adCursor.getDate() + 1); } html += '
'; // Time grid body html += `
`; // Time gutter html += '
'; for (let h = HOURS_START; h < HOURS_END; h++) { const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; html += `
${label}
`; } html += '
'; // Day columns const colCursor = new Date(monday); for (let d = 0; d < 7; d++) { const dateKey = toDateKey(colCursor); const dayEvts = (eventsByDate.get(dateKey) || []).filter(e => !isAllDayEvent(e)); html += `
`; // Hour lines for (let h = HOURS_START; h < HOURS_END; h++) { html += `
`; } // Positioned events dayEvts.forEach(e => { const startDate = new Date(e.startTimeEpoch || new Date(e.startTime).getTime()); const endEpoch = e.endTimeEpoch || e.endTime ? new Date(e.endTime).getTime() : (startDate.getTime() + 3600000); const endDate = new Date(endEpoch); const startMinutes = startDate.getHours() * 60 + startDate.getMinutes(); const endMinutes = endDate.getHours() * 60 + endDate.getMinutes(); const durationMinutes = Math.max(endMinutes - startMinutes, 15); const topPx = ((startMinutes - HOURS_START * 60) / 15) * SLOT_HEIGHT; const heightPx = Math.max((durationMinutes / 15) * SLOT_HEIGHT, SLOT_HEIGHT); const blockClass = e.blockType ? `block-${e.blockType}` : ''; html += `
${esc(truncate(e.title, 20))}
${esc(e.timeFormatted)}
`; }); html += '
'; colCursor.setDate(colCursor.getDate() + 1); } html += '
'; container.innerHTML = html; // Auto-scroll to current hour const body = container.querySelector('.cal-week-body'); if (body) { const now = new Date(); const currentMinutes = now.getHours() * 60 + now.getMinutes(); const scrollTarget = ((currentMinutes - HOURS_START * 60) / 15) * SLOT_HEIGHT - 100; body.scrollTop = Math.max(0, scrollTarget); } } function prevWeek() { const step = isMobileView() ? 1 : 7; currentWeekDate.setDate(currentWeekDate.getDate() - step); loadWeek(); } function nextWeek() { const step = isMobileView() ? 1 : 7; currentWeekDate.setDate(currentWeekDate.getDate() + step); loadWeek(); } function goToThisWeek() { currentWeekDate = new Date(); loadWeek(); } // ============ Mobile: Single-day swipe view ============ function renderMobileDay(container) { const dateKey = toDateKey(currentWeekDate); const today = toDateKey(new Date()); const allDayEvts = (groupByDate(weekEvents).get(dateKey) || []).filter(isAllDayEvent); const timedEvts = (groupByDate(weekEvents).get(dateKey) || []).filter(e => !isAllDayEvent(e)); const totalSlots = (HOURS_END - HOURS_START) * 4; const gridHeight = totalSlots * SLOT_HEIGHT; let html = '
'; html += `
${esc(currentWeekDate.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }))}
`; if (allDayEvts.length) { html += '
'; allDayEvts.forEach(e => { const blockClass = e.blockType ? `block-${e.blockType}` : ''; html += `
${esc(truncate(e.title, 30))}
`; }); html += '
'; } html += `
`; html += '
'; for (let h = HOURS_START; h < HOURS_END; h++) { const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`; html += `
${label}
`; } html += '
'; html += `
`; for (let h = HOURS_START; h < HOURS_END; h++) { html += `
`; } timedEvts.forEach(e => { const startDate = new Date(e.startTimeEpoch || new Date(e.startTime).getTime()); const endEpoch = e.endTimeEpoch || (e.endTime ? new Date(e.endTime).getTime() : (startDate.getTime() + 3600000)); const endDate = new Date(endEpoch); const startMinutes = startDate.getHours() * 60 + startDate.getMinutes(); const endMinutes = endDate.getHours() * 60 + endDate.getMinutes(); const durationMinutes = Math.max(endMinutes - startMinutes, 15); const topPx = ((startMinutes - HOURS_START * 60) / 15) * SLOT_HEIGHT; const heightPx = Math.max((durationMinutes / 15) * SLOT_HEIGHT, SLOT_HEIGHT); const blockClass = e.blockType ? `block-${e.blockType}` : ''; html += `
${esc(truncate(e.title, 30))}
${esc(e.timeFormatted)}
`; }); html += '
'; container.innerHTML = html; // Auto-scroll to current hour if it's today const body = container.querySelector('.cal-mobile-day-body'); if (body && dateKey === today) { const now = new Date(); const minutes = now.getHours() * 60 + now.getMinutes(); body.scrollTop = Math.max(0, ((minutes - HOURS_START * 60) / 15) * SLOT_HEIGHT - 100); } // Swipe to change day if (weekSwipeCleanup) weekSwipeCleanup(); if (GoingsOn.touch?.isTouchDevice && GoingsOn.touch.addSwipeNavigation) { weekSwipeCleanup = GoingsOn.touch.addSwipeNavigation(container, { onLeft: nextWeek, onRight: prevWeek, }); } } // ============ Namespace ============ GoingsOn.eventsCalendar = { loadMonth, loadWeek, prevMonth, nextMonth, goToThisMonth, prevWeek, nextWeek, goToThisWeek, toggleDayDetail, }; })();