Skip to main content

max / goingson

17.5 KB · 424 lines History Blame Raw
1 /**
2 * GoingsOn - Events Calendar Module
3 * Month grid and week grid views for events.
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttr;
10
11 let currentMonthDate = new Date();
12 let currentWeekDate = new Date();
13 let monthEvents = [];
14 let weekEvents = [];
15 let weekSwipeCleanup = null;
16
17 function isMobileView() {
18 // Route through the central UI-mode helper. The previous 600px
19 // threshold was arbitrary and disagreed with the 768px breakpoint
20 // used elsewhere; mode-based switching is the canonical signal now.
21 return !!GoingsOn.viewport?.isMobile();
22 }
23
24 // ============ Date Helpers ============
25
26 function getMonday(date) {
27 const d = new Date(date);
28 const day = d.getDay();
29 const diff = (day + 6) % 7;
30 d.setDate(d.getDate() - diff);
31 d.setHours(0, 0, 0, 0);
32 return d;
33 }
34
35 function toDateKey(date) {
36 const y = date.getFullYear();
37 const m = String(date.getMonth() + 1).padStart(2, '0');
38 const d = String(date.getDate()).padStart(2, '0');
39 return `${y}-${m}-${d}`;
40 }
41
42 function groupByDate(events) {
43 const map = new Map();
44 for (const e of events) {
45 const d = new Date(e.startTimeEpoch || new Date(e.startTime).getTime());
46 const key = toDateKey(d);
47 if (!map.has(key)) map.set(key, []);
48 map.get(key).push(e);
49 }
50 return map;
51 }
52
53 function truncate(str, len) {
54 if (!str || str.length <= len) return str || '';
55 return str.substring(0, len - 1) + '\u2026';
56 }
57
58 function formatMonthLabel(date) {
59 return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
60 }
61
62 function formatWeekLabel(monday) {
63 const sunday = new Date(monday);
64 sunday.setDate(monday.getDate() + 6);
65 const opts = { month: 'short', day: 'numeric' };
66 const start = monday.toLocaleDateString('en-US', opts);
67 const end = sunday.toLocaleDateString('en-US', { ...opts, year: 'numeric' });
68 return `${start} \u2013 ${end}`;
69 }
70
71 // ============ Month View ============
72
73 async function loadMonth() {
74 const first = new Date(currentMonthDate.getFullYear(), currentMonthDate.getMonth(), 1);
75 const startOffset = (first.getDay() + 6) % 7;
76 const gridStart = new Date(first);
77 gridStart.setDate(first.getDate() - startOffset);
78
79 const lastDay = new Date(first.getFullYear(), first.getMonth() + 1, 0);
80 const gridEnd = new Date(lastDay);
81 const endOffset = (7 - ((lastDay.getDay() + 6) % 7 + 1)) % 7;
82 gridEnd.setDate(lastDay.getDate() + endOffset + 1);
83
84 try {
85 monthEvents = await GoingsOn.api.events.listBetween(
86 gridStart.toISOString(), gridEnd.toISOString()
87 );
88 } catch (err) {
89 console.error('Failed to load month events:', err);
90 monthEvents = [];
91 }
92
93 renderMonthGrid(first, gridStart, gridEnd);
94 const label = document.getElementById('month-calendar-label');
95 if (label) label.textContent = formatMonthLabel(first);
96 }
97
98 function renderMonthGrid(firstOfMonth, gridStart, gridEnd) {
99 const container = document.getElementById('month-calendar-grid');
100 if (!container) return;
101 const eventsByDate = groupByDate(monthEvents);
102 const today = toDateKey(new Date());
103 const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
104
105 let html = '<div class="cal-month-grid">';
106 html += '<div class="cal-month-header">';
107 for (const d of dayHeaders) {
108 html += `<div class="cal-month-day-header">${d}</div>`;
109 }
110 html += '</div><div class="cal-month-cells">';
111
112 const cursor = new Date(gridStart);
113 while (cursor < gridEnd) {
114 const dateKey = toDateKey(cursor);
115 const isCurrentMonth = cursor.getMonth() === firstOfMonth.getMonth();
116 const isToday = dateKey === today;
117 const dayEvents = eventsByDate.get(dateKey) || [];
118
119 const classes = ['cal-month-cell'];
120 if (!isCurrentMonth) classes.push('other-month');
121 if (isToday) classes.push('today');
122
123 html += `<div class="${classes.join(' ')}" data-date="${escAttr(dateKey)}" onclick="GoingsOn.eventsCalendar.toggleDayDetail('${escAttr(dateKey)}')">`;
124 html += `<div class="cal-month-cell-header"><span class="cal-day-number">${cursor.getDate()}</span></div>`;
125
126 const maxShow = 3;
127 dayEvents.slice(0, maxShow).forEach(e => {
128 const blockClass = e.blockType ? `block-${e.blockType}` : '';
129 html += `<div class="cal-event-chip ${blockClass}" onclick="event.stopPropagation(); GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttr(e.title)}">${esc(truncate(e.title, 18))}</div>`;
130 });
131 if (dayEvents.length > maxShow) {
132 html += `<div class="cal-event-more">+${dayEvents.length - maxShow} more</div>`;
133 }
134
135 html += '</div>';
136 cursor.setDate(cursor.getDate() + 1);
137 }
138
139 html += '</div></div>';
140 container.innerHTML = html;
141
142 // Hide day detail when month changes
143 const detail = document.getElementById('month-day-detail');
144 if (detail) detail.classList.add('hidden');
145 }
146
147 function toggleDayDetail(dateKey) {
148 const detail = document.getElementById('month-day-detail');
149 if (!detail) return;
150
151 if (detail.dataset.date === dateKey && !detail.classList.contains('hidden')) {
152 detail.classList.add('hidden');
153 return;
154 }
155
156 const eventsByDate = groupByDate(monthEvents);
157 const dayEvents = eventsByDate.get(dateKey) || [];
158 const dateObj = new Date(dateKey + 'T12:00:00');
159 const dayLabel = dateObj.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
160
161 let html = `<h3>${esc(dayLabel)}</h3>`;
162 if (dayEvents.length === 0) {
163 html += '<p class="no-events-day">No events this day.</p>';
164 } else {
165 dayEvents.forEach(e => {
166 const blockClass = e.blockType ? `block-${e.blockType}` : '';
167 html += `<div class="cal-day-detail-event ${blockClass}" onclick="GoingsOn.events.open('${escAttr(e.id)}')">
168 <span class="cal-detail-time">${esc(e.timeFormatted)}</span>
169 <span class="cal-detail-title">${esc(e.title)}</span>
170 ${e.location ? `<span class="cal-detail-location">${esc(e.location)}</span>` : ''}
171 </div>`;
172 });
173 }
174
175 detail.dataset.date = dateKey;
176 detail.innerHTML = html;
177 detail.classList.remove('hidden');
178 }
179
180 function prevMonth() { currentMonthDate.setMonth(currentMonthDate.getMonth() - 1); loadMonth(); }
181 function nextMonth() { currentMonthDate.setMonth(currentMonthDate.getMonth() + 1); loadMonth(); }
182 function goToThisMonth() { currentMonthDate = new Date(); loadMonth(); }
183
184 // ============ Week View ============
185
186 const SLOT_HEIGHT = 12;
187 const HOURS_START = 6;
188 const HOURS_END = 22;
189
190 async function loadWeek() {
191 const monday = getMonday(currentWeekDate);
192 const sunday = new Date(monday);
193 sunday.setDate(monday.getDate() + 7);
194
195 try {
196 weekEvents = await GoingsOn.api.events.listBetween(
197 monday.toISOString(), sunday.toISOString()
198 );
199 } catch (err) {
200 console.error('Failed to load week events:', err);
201 weekEvents = [];
202 }
203
204 renderWeekGrid(monday);
205 const label = document.getElementById('week-calendar-label');
206 if (label) {
207 label.textContent = isMobileView()
208 ? currentWeekDate.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
209 : formatWeekLabel(monday);
210 }
211 }
212
213 function isAllDayEvent(e) {
214 if (!e.endTime) return false;
215 const startEpoch = e.startTimeEpoch || new Date(e.startTime).getTime();
216 const endEpoch = e.endTimeEpoch || new Date(e.endTime).getTime();
217 return (endEpoch - startEpoch) >= 23 * 3600 * 1000;
218 }
219
220 function renderWeekGrid(monday) {
221 const container = document.getElementById('week-calendar-grid');
222 if (!container) return;
223 if (isMobileView()) {
224 renderMobileDay(container);
225 return;
226 }
227 const eventsByDate = groupByDate(weekEvents);
228 const today = toDateKey(new Date());
229 const totalSlots = (HOURS_END - HOURS_START) * 4;
230 const gridHeight = totalSlots * SLOT_HEIGHT;
231
232 let html = '<div class="cal-week-grid">';
233
234 // Header row
235 html += '<div class="cal-week-header"><div class="cal-week-time-gutter"></div>';
236 const cursor = new Date(monday);
237 for (let d = 0; d < 7; d++) {
238 const dateKey = toDateKey(cursor);
239 const dayName = cursor.toLocaleDateString('en-US', { weekday: 'short' });
240 html += `<div class="cal-week-day-header ${dateKey === today ? 'today' : ''}">
241 <span class="cal-week-day-name">${dayName}</span>
242 <span class="cal-week-day-num">${cursor.getDate()}</span>
243 </div>`;
244 cursor.setDate(cursor.getDate() + 1);
245 }
246 html += '</div>';
247
248 // All-day row
249 html += '<div class="cal-week-allday-row"><div class="cal-week-time-gutter cal-allday-label">All Day</div>';
250 const adCursor = new Date(monday);
251 for (let d = 0; d < 7; d++) {
252 const dateKey = toDateKey(adCursor);
253 const dayEvts = (eventsByDate.get(dateKey) || []).filter(isAllDayEvent);
254 html += '<div class="cal-week-allday-cell">';
255 dayEvts.forEach(e => {
256 const blockClass = e.blockType ? `block-${e.blockType}` : '';
257 html += `<div class="cal-event-chip ${blockClass}" onclick="GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttr(e.title)}">${esc(truncate(e.title, 14))}</div>`;
258 });
259 html += '</div>';
260 adCursor.setDate(adCursor.getDate() + 1);
261 }
262 html += '</div>';
263
264 // Time grid body
265 html += `<div class="cal-week-body" style="height: ${gridHeight}px;">`;
266
267 // Time gutter
268 html += '<div class="cal-week-time-gutter">';
269 for (let h = HOURS_START; h < HOURS_END; h++) {
270 const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`;
271 html += `<div class="cal-week-hour-label" style="top: ${(h - HOURS_START) * 4 * SLOT_HEIGHT}px;">${label}</div>`;
272 }
273 html += '</div>';
274
275 // Day columns
276 const colCursor = new Date(monday);
277 for (let d = 0; d < 7; d++) {
278 const dateKey = toDateKey(colCursor);
279 const dayEvts = (eventsByDate.get(dateKey) || []).filter(e => !isAllDayEvent(e));
280 html += `<div class="cal-week-day-col ${dateKey === today ? 'today' : ''}">`;
281
282 // Hour lines
283 for (let h = HOURS_START; h < HOURS_END; h++) {
284 html += `<div class="cal-week-hour-line" style="top: ${(h - HOURS_START) * 4 * SLOT_HEIGHT}px;"></div>`;
285 }
286
287 // Positioned events
288 dayEvts.forEach(e => {
289 const startDate = new Date(e.startTimeEpoch || new Date(e.startTime).getTime());
290 const endEpoch = e.endTimeEpoch || e.endTime ? new Date(e.endTime).getTime() : (startDate.getTime() + 3600000);
291 const endDate = new Date(endEpoch);
292
293 const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
294 const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
295 const durationMinutes = Math.max(endMinutes - startMinutes, 15);
296
297 const topPx = ((startMinutes - HOURS_START * 60) / 15) * SLOT_HEIGHT;
298 const heightPx = Math.max((durationMinutes / 15) * SLOT_HEIGHT, SLOT_HEIGHT);
299 const blockClass = e.blockType ? `block-${e.blockType}` : '';
300
301 html += `<div class="cal-week-event ${blockClass}" style="top: ${topPx}px; height: ${heightPx}px;" onclick="GoingsOn.events.open('${escAttr(e.id)}')" title="${escAttr(e.title + ' ' + e.timeFormatted)}">
302 <div class="cal-week-event-title">${esc(truncate(e.title, 20))}</div>
303 <div class="cal-week-event-time">${esc(e.timeFormatted)}</div>
304 </div>`;
305 });
306
307 html += '</div>';
308 colCursor.setDate(colCursor.getDate() + 1);
309 }
310
311 html += '</div></div>';
312 container.innerHTML = html;
313
314 // Auto-scroll to current hour
315 const body = container.querySelector('.cal-week-body');
316 if (body) {
317 const now = new Date();
318 const currentMinutes = now.getHours() * 60 + now.getMinutes();
319 const scrollTarget = ((currentMinutes - HOURS_START * 60) / 15) * SLOT_HEIGHT - 100;
320 body.scrollTop = Math.max(0, scrollTarget);
321 }
322 }
323
324 function prevWeek() {
325 const step = isMobileView() ? 1 : 7;
326 currentWeekDate.setDate(currentWeekDate.getDate() - step);
327 loadWeek();
328 }
329 function nextWeek() {
330 const step = isMobileView() ? 1 : 7;
331 currentWeekDate.setDate(currentWeekDate.getDate() + step);
332 loadWeek();
333 }
334 function goToThisWeek() { currentWeekDate = new Date(); loadWeek(); }
335
336 // ============ Mobile: Single-day swipe view ============
337
338 function renderMobileDay(container) {
339 const dateKey = toDateKey(currentWeekDate);
340 const today = toDateKey(new Date());
341 const allDayEvts = (groupByDate(weekEvents).get(dateKey) || []).filter(isAllDayEvent);
342 const timedEvts = (groupByDate(weekEvents).get(dateKey) || []).filter(e => !isAllDayEvent(e));
343 const totalSlots = (HOURS_END - HOURS_START) * 4;
344 const gridHeight = totalSlots * SLOT_HEIGHT;
345
346 let html = '<div class="cal-mobile-day">';
347 html += `<div class="cal-mobile-day-header${dateKey === today ? ' today' : ''}">
348 ${esc(currentWeekDate.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }))}
349 </div>`;
350
351 if (allDayEvts.length) {
352 html += '<div class="cal-mobile-allday">';
353 allDayEvts.forEach(e => {
354 const blockClass = e.blockType ? `block-${e.blockType}` : '';
355 html += `<div class="cal-event-chip ${blockClass}" onclick="GoingsOn.events.open('${escAttr(e.id)}')">${esc(truncate(e.title, 30))}</div>`;
356 });
357 html += '</div>';
358 }
359
360 html += `<div class="cal-mobile-day-body" style="height: ${gridHeight}px;">`;
361 html += '<div class="cal-week-time-gutter">';
362 for (let h = HOURS_START; h < HOURS_END; h++) {
363 const label = h === 0 ? '12 AM' : h < 12 ? `${h} AM` : h === 12 ? '12 PM' : `${h - 12} PM`;
364 html += `<div class="cal-week-hour-label" style="top: ${(h - HOURS_START) * 4 * SLOT_HEIGHT}px;">${label}</div>`;
365 }
366 html += '</div>';
367
368 html += `<div class="cal-mobile-day-col">`;
369 for (let h = HOURS_START; h < HOURS_END; h++) {
370 html += `<div class="cal-week-hour-line" style="top: ${(h - HOURS_START) * 4 * SLOT_HEIGHT}px;"></div>`;
371 }
372 timedEvts.forEach(e => {
373 const startDate = new Date(e.startTimeEpoch || new Date(e.startTime).getTime());
374 const endEpoch = e.endTimeEpoch || (e.endTime ? new Date(e.endTime).getTime() : (startDate.getTime() + 3600000));
375 const endDate = new Date(endEpoch);
376 const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
377 const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
378 const durationMinutes = Math.max(endMinutes - startMinutes, 15);
379 const topPx = ((startMinutes - HOURS_START * 60) / 15) * SLOT_HEIGHT;
380 const heightPx = Math.max((durationMinutes / 15) * SLOT_HEIGHT, SLOT_HEIGHT);
381 const blockClass = e.blockType ? `block-${e.blockType}` : '';
382 html += `<div class="cal-week-event ${blockClass}" style="top: ${topPx}px; height: ${heightPx}px;" onclick="GoingsOn.events.open('${escAttr(e.id)}')">
383 <div class="cal-week-event-title">${esc(truncate(e.title, 30))}</div>
384 <div class="cal-week-event-time">${esc(e.timeFormatted)}</div>
385 </div>`;
386 });
387 html += '</div></div></div>';
388
389 container.innerHTML = html;
390
391 // Auto-scroll to current hour if it's today
392 const body = container.querySelector('.cal-mobile-day-body');
393 if (body && dateKey === today) {
394 const now = new Date();
395 const minutes = now.getHours() * 60 + now.getMinutes();
396 body.scrollTop = Math.max(0, ((minutes - HOURS_START * 60) / 15) * SLOT_HEIGHT - 100);
397 }
398
399 // Swipe to change day
400 if (weekSwipeCleanup) weekSwipeCleanup();
401 if (GoingsOn.touch?.isTouchDevice && GoingsOn.touch.addSwipeNavigation) {
402 weekSwipeCleanup = GoingsOn.touch.addSwipeNavigation(container, {
403 onLeft: nextWeek,
404 onRight: prevWeek,
405 });
406 }
407 }
408
409 // ============ Namespace ============
410
411 GoingsOn.eventsCalendar = {
412 loadMonth,
413 loadWeek,
414 prevMonth,
415 nextMonth,
416 goToThisMonth,
417 prevWeek,
418 nextWeek,
419 goToThisWeek,
420 toggleDayDetail,
421 };
422
423 })();
424