Skip to main content

max / goingson

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