/**
* 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 += '
';
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 += ``;
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 += '';
// 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 += ``;
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,
};
})();