Skip to main content

max / goingson

36.3 KB · 856 lines History Blame Raw
1 /**
2 * GoingsOn - Events Module
3 * Event list, CRUD, rendering
4 */
5
6 // ============ Events Module ============
7
8 (function() {
9 'use strict';
10 const esc = GoingsOn.utils.escapeHtml;
11 const escAttr = GoingsOn.utils.escapeAttr;
12
13 // ============ Reminder Presets ============
14
15 // Common lead times users actually want; renders as a checkbox group in
16 // the event form. Seconds-before-start_time, matching the backend column.
17 const REMINDER_PRESETS = [
18 { seconds: 0, label: 'At time of event' },
19 { seconds: 300, label: '5 minutes before' },
20 { seconds: 900, label: '15 minutes before' },
21 { seconds: 1800, label: '30 minutes before' },
22 { seconds: 3600, label: '1 hour before' },
23 { seconds: 86400, label: '1 day before' },
24 ];
25
26 function buildRemindersHtml(event) {
27 const selected = new Set((event?.reminderOffsetsSeconds || []).map(Number));
28 const items = REMINDER_PRESETS.map(p => `
29 <label class="form-checkbox-label reminder-option">
30 <input type="checkbox" name="reminder_offset_${p.seconds}" value="${p.seconds}" ${selected.has(p.seconds) ? 'checked' : ''}>
31 <span>${esc(p.label)}</span>
32 </label>
33 `).join('');
34 return `
35 <div class="form-group reminders-group">
36 <label class="form-label">Reminders</label>
37 <div class="form-hint">Desktop notifications fire at the chosen lead times.</div>
38 <div class="reminder-options">${items}</div>
39 </div>
40 `;
41 }
42
43 function collectReminderOffsets(form) {
44 if (!form) return [];
45 return REMINDER_PRESETS
46 .filter(p => form.elements[`reminder_offset_${p.seconds}`]?.checked)
47 .map(p => p.seconds);
48 }
49
50 // ============ Virtual Scroller Instances ============
51 let upcomingEventsScroller = null;
52 let pastEventsScroller = null;
53 let recurringEventsScroller = null;
54
55 // ============ Selection State ============
56
57 const selectedEventIds = new Set();
58
59 function toggleEventSelection(id, event) {
60 if (event) event.stopPropagation();
61 if (selectedEventIds.has(id)) {
62 selectedEventIds.delete(id);
63 } else {
64 selectedEventIds.add(id);
65 }
66 updateEventSelectionUI();
67 }
68
69 function selectAllEvents() {
70 const events = [
71 ...(GoingsOn.state.upcomingEvents || []),
72 ...(GoingsOn.state.pastEvents || []),
73 ];
74 events.forEach(e => selectedEventIds.add(e.id));
75 updateEventSelectionUI();
76 }
77
78 function clearEventSelection() {
79 selectedEventIds.clear();
80 updateEventSelectionUI();
81 }
82
83 function updateEventSelectionUI() {
84 document.querySelectorAll('.event-select-cb').forEach(cb => {
85 cb.checked = selectedEventIds.has(cb.dataset.id);
86 });
87 const bar = document.getElementById('events-bulk-bar');
88 if (bar) {
89 bar.classList.toggle('hidden', selectedEventIds.size === 0);
90 const count = bar.querySelector('.bulk-count');
91 if (count) count.textContent = `${selectedEventIds.size} selected`;
92 }
93 }
94
95 async function bulkDeleteEvents() {
96 const count = selectedEventIds.size;
97 if (count === 0) return;
98 if (!await GoingsOn.ui.confirmDelete(`${count} event${count > 1 ? 's' : ''}`)) return;
99
100 const ids = [...selectedEventIds];
101 GoingsOn.cache.invalidate('events');
102 try {
103 await GoingsOn.api.events.bulkDelete(ids);
104 selectedEventIds.clear();
105 GoingsOn.ui.showToast(`${count} event${count > 1 ? 's' : ''} deleted`, 'success');
106 load();
107 } catch (err) {
108 GoingsOn.ui.showToast('Failed to delete events: ' + GoingsOn.utils.getErrorMessage(err), 'error');
109 }
110 }
111
112 // ============ Form Field Definitions ============
113
114 /**
115 * Build form field definitions for the event create/edit modal.
116 * @param {Object|null} event - Existing event for edit mode, or null for create
117 * @param {string|null} projectId - Pre-selected project ID, or null
118 * @returns {FormField[]} Array of form field definitions
119 */
120 /**
121 * Phase 7 Tier 5 — detect an existing all-day event so the form's
122 * "All day" checkbox pre-checks. Matches the calendar renderer's heuristic
123 * (duration ≥ 23 h) and also catches the canonical 00:00 → next-day-00:00
124 * shape we author below.
125 */
126 function _isAllDayEvent(event) {
127 if (!event?.start_time) return false;
128 const start = new Date(event.start_time);
129 const end = event.end_time ? new Date(event.end_time) : null;
130 if (!end) return false;
131 const durHours = (end - start) / (1000 * 60 * 60);
132 const startsAtMidnight = start.getHours() === 0 && start.getMinutes() === 0;
133 return durHours >= 23 && startsAtMidnight;
134 }
135
136 function getEventFormFields(event = null, projectId = null) {
137 const now = new Date();
138 const localISOTime = GoingsOn.utils.toLocalISOString(now);
139 const isAllDay = _isAllDayEvent(event);
140
141 const fields = [
142 {
143 name: 'is_all_day',
144 type: 'checkbox',
145 label: 'All day',
146 value: isAllDay,
147 hint: 'Removes the time component — the event spans the whole day.',
148 },
149 {
150 name: 'title',
151 type: 'text',
152 label: 'Title',
153 placeholder: 'Event title',
154 required: true,
155 value: event?.title || '',
156 validate: (v) => v && v.length > 200 ? 'Maximum 200 characters' : null,
157 },
158 {
159 name: 'description',
160 type: 'textarea',
161 label: 'Description',
162 placeholder: 'Event details...',
163 value: event?.description || '',
164 validate: (v) => v && v.length > 2000 ? 'Maximum 2000 characters' : null,
165 },
166 {
167 name: 'start_time',
168 type: 'text',
169 label: 'Start Date & Time',
170 placeholder: 'tomorrow 3pm, friday 10:00, 2026-12-25...',
171 required: true,
172 value: event?.start_time
173 ? new Date(event.start_time).toISOString().slice(0, 16)
174 : localISOTime,
175 transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
176 onInput: GoingsOn.utils.dateParsePreview,
177 },
178 {
179 name: 'end_time',
180 type: 'text',
181 label: 'End Time (optional)',
182 placeholder: 'tomorrow 5pm, friday 12:00...',
183 value: event?.end_time
184 ? new Date(event.end_time).toISOString().slice(0, 16)
185 : '',
186 transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v,
187 onInput: GoingsOn.utils.dateParsePreview,
188 validate: (v, data) => {
189 if (v && data.start_time && new Date(v) < new Date(data.start_time)) {
190 return 'End time must be after start time';
191 }
192 return null;
193 },
194 },
195 {
196 name: 'location',
197 type: 'text',
198 label: 'Location',
199 placeholder: 'Zoom / Office / Coffee Shop',
200 value: event?.location || '',
201 validate: (v) => v && v.length > 200 ? 'Maximum 200 characters' : null,
202 },
203 ];
204
205 // Recurrence select field
206 const RECURRENCE_OPTIONS = GoingsOn.taskForms.RECURRENCE_OPTIONS;
207 fields.push({
208 name: 'recurrence',
209 type: 'select',
210 label: 'Recurrence',
211 hint: 'Recurring events appear automatically on matching days',
212 hintExtraHtml: GoingsOn.taskForms.buildRecurrenceConfigHtml(event?.recurrenceRule, 'event'),
213 options: RECURRENCE_OPTIONS.map(r => ({
214 ...r,
215 selected: r.value === (event?.recurrence || 'None'),
216 })),
217 value: event?.recurrence || 'None',
218 });
219
220 // Block type select field
221 fields.push({
222 name: 'block_type',
223 type: 'select',
224 label: 'Type',
225 options: [
226 { value: '', label: 'Regular Event' },
227 { value: 'free_time', label: 'Free Time' },
228 { value: 'personal', label: 'Personal' },
229 { value: 'vacation', label: 'Vacation' },
230 { value: 'focus', label: 'Focus' },
231 ],
232 value: event?.blockType || '',
233 });
234
235 // Contact select field
236 fields.push({
237 name: 'contact_id',
238 type: 'select',
239 label: 'Contact',
240 options: [
241 { value: '', label: 'No Contact' },
242 ...(GoingsOn.state.contacts || []).map(c => ({
243 value: c.id,
244 label: c.displayName || c.display_name,
245 selected: c.id === event?.contactId,
246 })),
247 ],
248 value: event?.contactId || '',
249 });
250
251 // Add hidden project_id if specified
252 if (projectId) {
253 fields.unshift({
254 name: 'project_id',
255 type: 'hidden',
256 value: projectId,
257 });
258 }
259
260 return fields;
261 }
262
263 // ============ Core Functions ============
264
265 // Events stored in centralized state for virtual scrolling
266 GoingsOn.state.set('upcomingEvents', []);
267 GoingsOn.state.set('pastEvents', []);
268 GoingsOn.state.set('recurringEvents', []);
269
270 /**
271 * Fetch all events and render the segmented list: Recurring (templates only) at top,
272 * Upcoming in the middle, Past (collapsed) at the bottom. Recurring instances are
273 * shown in Upcoming/Past based on their occurrence date.
274 */
275 /**
276 * Re-fetch events after a filter checkbox change. Invalidates the
277 * view cache so load() doesn't short-circuit on the freshness check.
278 */
279 function onFilterChange() {
280 GoingsOn.cache.invalidate('events');
281 load();
282 }
283
284 async function load() {
285 if (GoingsOn.cache.isFresh('events')) return;
286
287 const upcomingContainer = document.getElementById('event-list-container');
288 const pastContainer = document.getElementById('past-event-list-container');
289 const recurringContainer = document.getElementById('recurring-event-list-container');
290 const pastSection = document.getElementById('past-events-section');
291 const recurringSection = document.getElementById('recurring-events-section');
292 const futureHeading = document.getElementById('future-events-heading');
293 const pastCount = document.getElementById('past-events-count');
294 const recurringCount = document.getElementById('recurring-events-count');
295 const eventTable = document.getElementById('event-table');
296
297 try {
298 const showSnoozed = document.getElementById('filter-events-snoozed')?.checked || false;
299 // list_events excludes snoozed by default; merge in list_snoozed_events
300 // when the user opts in. De-dupe by id since recurring expansion may
301 // collide with a snoozed template.
302 const [mainEvents, snoozedEvents] = await Promise.all([
303 GoingsOn.api.events.list(),
304 showSnoozed ? GoingsOn.api.events.listSnoozed() : Promise.resolve([]),
305 ]);
306 let events = mainEvents;
307 if (snoozedEvents.length > 0) {
308 const seen = new Set(events.map(e => e.id));
309 for (const ev of snoozedEvents) {
310 if (!seen.has(ev.id)) {
311 events.push(ev);
312 seen.add(ev.id);
313 }
314 }
315 }
316
317 if (events.length === 0) {
318 eventTable.style.display = 'none';
319 pastSection.classList.add('hidden');
320 recurringSection.classList.add('hidden');
321 if (futureHeading) futureHeading.classList.add('hidden');
322 upcomingContainer.innerHTML = GoingsOn.ui.renderEmptyState('No events scheduled.', 'Add Event', 'GoingsOn.events.openNew()', 'events');
323 [upcomingEventsScroller, pastEventsScroller, recurringEventsScroller].forEach(s => s && s.destroy());
324 upcomingEventsScroller = pastEventsScroller = recurringEventsScroller = null;
325 return;
326 }
327
328 // Events come pre-sorted by start_time ASC from backend.
329 events = events.map(e => ({ ...e, displayTitle: e.title }));
330
331 // Recurring section shows TEMPLATES only (the parent rule), not each expanded
332 // occurrence — so users can find the rule itself without scrolling past 50
333 // weekly instances. Templates are identified by recurrence !== 'None' and
334 // !isRecurringInstance (the parent row, not a generated copy).
335 const recurring = events.filter(e => e.recurrence && e.recurrence !== 'None' && !e.isRecurringInstance);
336 const nonTemplate = events.filter(e => !(e.recurrence && e.recurrence !== 'None' && !e.isRecurringInstance));
337
338 GoingsOn.state.set('recurringEvents', recurring);
339 GoingsOn.state.set('upcomingEvents', nonTemplate.filter(e => !e.isPast));
340 GoingsOn.state.set('pastEvents', nonTemplate.filter(e => e.isPast).reverse());
341
342 // --- Recurring (top) ---
343 if (recurring.length > 0) {
344 recurringSection.classList.remove('hidden');
345 recurringCount.textContent = recurring.length;
346 if (!recurringEventsScroller) {
347 recurringEventsScroller = new GoingsOn.VirtualScroller({
348 container: recurringContainer,
349 renderItem: (e, i) => renderEventRow(e, i, false, true),
350 getItems: () => GoingsOn.state.recurringEvents,
351 rowHeight: { estimated: 52, measure: true },
352 overscan: 3,
353 });
354 } else {
355 recurringEventsScroller.refresh();
356 }
357 } else {
358 recurringSection.classList.add('hidden');
359 if (recurringEventsScroller) {
360 recurringEventsScroller.destroy();
361 recurringEventsScroller = null;
362 }
363 }
364
365 // --- Upcoming (middle) ---
366 if (GoingsOn.state.upcomingEvents.length > 0) {
367 eventTable.style.display = 'flex';
368 if (futureHeading) futureHeading.classList.remove('hidden');
369 if (!upcomingEventsScroller) {
370 upcomingEventsScroller = new GoingsOn.VirtualScroller({
371 container: upcomingContainer,
372 renderItem: renderEventRow,
373 getItems: () => GoingsOn.state.upcomingEvents,
374 rowHeight: { estimated: 52, measure: true },
375 overscan: 5,
376 });
377 } else {
378 upcomingEventsScroller.refresh();
379 }
380 } else {
381 eventTable.style.display = 'none';
382 if (futureHeading) futureHeading.classList.add('hidden');
383 upcomingContainer.innerHTML = '<div class="loading">No upcoming events</div>';
384 if (upcomingEventsScroller) {
385 upcomingEventsScroller.destroy();
386 upcomingEventsScroller = null;
387 }
388 }
389
390 // --- Past (bottom) ---
391 if (GoingsOn.state.pastEvents.length > 0) {
392 pastSection.classList.remove('hidden');
393 pastCount.textContent = GoingsOn.state.pastEvents.length;
394 if (!pastEventsScroller) {
395 pastEventsScroller = new GoingsOn.VirtualScroller({
396 container: pastContainer,
397 renderItem: (e, i) => renderEventRow(e, i, true),
398 getItems: () => GoingsOn.state.pastEvents,
399 rowHeight: { estimated: 52, measure: true },
400 overscan: 3,
401 });
402 } else {
403 pastEventsScroller.refresh();
404 }
405 } else {
406 pastSection.classList.add('hidden');
407 if (pastEventsScroller) {
408 pastEventsScroller.destroy();
409 pastEventsScroller = null;
410 }
411 }
412 GoingsOn.cache.markLoaded('events');
413 } catch (err) {
414 upcomingContainer.innerHTML = `<div class="error-state error-state--padded">Failed to load events. <button class="btn-link" onclick="GoingsOn.cache.invalidate('events'); GoingsOn.events.load()">Try again</button></div>`;
415 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load events'), 'error', {
416 action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('events'); load(); } },
417 duration: 8000,
418 });
419 }
420 }
421
422 /**
423 * Render a single event row as a div (for virtual scrolling).
424 * @param {Object} e - Event object
425 * @param {number} index - Event index
426 * @param {boolean} isPast - Whether this is a past event
427 * @returns {string} HTML string
428 */
429 function renderEventRow(e, index, isPast = false, isRecurring = false) {
430 const displayTitle = e.displayTitle || e.title;
431 const startDate = new Date(e.startTime);
432 const monthName = startDate.toLocaleDateString('en-US', { month: 'short' });
433
434 // Recurring template row: replace the date cell with the recurrence pattern label
435 // so users see "Weekly · Mon Wed Fri" rather than a single arbitrary start date.
436 if (isRecurring) {
437 const patternLabel = e.recurrenceDisplay || e.recurrence || 'Recurring';
438 return `
439 <div class="event-row-virtual event-recurring"
440 data-id="${escAttr(e.id)}"
441 onclick="GoingsOn.events.open('${escAttr(e.id)}')"
442 oncontextmenu="GoingsOn.contextMenus.showEvent(event, '${escAttr(e.id)}')"
443 tabindex="0" role="row">
444 <div class="event-cell event-cell-date"><span class="event-recurrence-pattern">${esc(patternLabel)}</span></div>
445 <div class="event-cell event-cell-time">${e.timeFormatted}</div>
446 <div class="event-cell event-cell-title">${esc(displayTitle)}</div>
447 <div class="event-cell event-cell-location">${e.location ? esc(e.location) : '-'}</div>
448 <div class="event-cell" style="text-align: right;" onclick="event.stopPropagation();">
449 <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEvent(event, '${escAttr(e.id)}')" title="Actions" aria-label="Event actions">&#x22EE;</button>
450 </div>
451 </div>
452 `;
453 }
454
455 // Mobile: insert date group header when date changes from previous item
456 let dateHeader = '';
457 if (GoingsOn.touch?.isTouchDevice && index > 0) {
458 const items = isPast ? (GoingsOn.state.pastEvents || []) : (GoingsOn.state.upcomingEvents || []);
459 const prevItem = items[index - 1];
460 if (prevItem) {
461 const prevDate = new Date(prevItem.startTime).toDateString();
462 const curDate = startDate.toDateString();
463 if (prevDate !== curDate) {
464 const dayName = startDate.toLocaleDateString('en-US', { weekday: 'long' });
465 dateHeader = `<div class="event-date-group-header">${dayName}, ${startDate.getDate()} ${monthName}</div>`;
466 }
467 }
468 } else if (GoingsOn.touch?.isTouchDevice && index === 0) {
469 const dayName = startDate.toLocaleDateString('en-US', { weekday: 'long' });
470 dateHeader = `<div class="event-date-group-header">${dayName}, ${startDate.getDate()} ${monthName}</div>`;
471 }
472
473 return `
474 ${dateHeader}
475 <div class="event-row-virtual ${e.isPast || isPast ? 'event-past' : ''}"
476 data-id="${escAttr(e.id)}"
477 onclick="GoingsOn.events.open('${escAttr(e.id)}')"
478 oncontextmenu="GoingsOn.contextMenus.showEvent(event, '${escAttr(e.id)}')"
479 tabindex="0" role="row">
480 <div class="event-cell event-cell--shrink">
481 <input type="checkbox" class="bulk-checkbox event-select-cb" data-id="${escAttr(e.id)}"
482 onclick="event.stopPropagation(); GoingsOn.events.toggleEventSelection('${escAttr(e.id)}', event)"
483 aria-label="Select event">
484 </div>
485 <div class="event-cell event-cell-date">
486 <span class="event-date-num">${startDate.getDate()} ${monthName}</span>
487 <span class="event-date-badge event-proximity-${e.proximityClass || 'default'}">${e.proximityLabel || ''}</span>
488 </div>
489 <div class="event-cell event-cell-time">${e.timeFormatted}</div>
490 <div class="event-cell event-cell-title">${esc(displayTitle)}</div>
491 <div class="event-cell event-cell-location">${e.location ? esc(e.location) : '-'}</div>
492 <div class="event-cell" style="text-align: right;" onclick="event.stopPropagation();">
493 <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEvent(event, '${escAttr(e.id)}')" title="Actions" aria-label="Event actions">&#x22EE;</button>
494 </div>
495 </div>
496 `;
497 }
498
499 function openNew() {
500 GoingsOn.ui.openFormModal({
501 title: 'New Event',
502 entityType: 'event',
503 isEdit: false,
504 fields: getEventFormFields(),
505 extraContent: buildRemindersHtml(null),
506 onSubmit: create,
507 });
508 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
509 }
510
511 /**
512 * Open the new event form modal pre-filled for a specific project.
513 * @param {string} projectId - Project to pre-select in the form
514 */
515 function openNewForProject(projectId) {
516 const project = GoingsOn.getProjectsCache().find(p => p.id === projectId);
517
518 GoingsOn.ui.openFormModal({
519 title: 'New Event',
520 entityType: 'event',
521 isEdit: false,
522 fields: getEventFormFields(null, projectId),
523 presetData: { project_id: projectId },
524 onSubmit: create,
525 extraContent: (project ? `
526 <div class="form-group">
527 <label class="form-label">Project</label>
528 <input type="text" class="form-input" value="${esc(project.name)}" disabled>
529 </div>
530 ` : '') + buildRemindersHtml(null),
531 });
532 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
533 }
534
535 /**
536 * Create a new event from form data.
537 * @param {Object} data - Form data with title, description, start_time, end_time, location, etc.
538 */
539 /**
540 * Phase 7 Tier 5 — snap start/end to midnight pair when "All day" is set.
541 * The backend doesn't have a dedicated all-day flag, so we author the
542 * canonical 00:00 → next-day-00:00 shape the calendar renderer detects.
543 */
544 function _normalizeForAllDay(data) {
545 if (!data.is_all_day) return { startTime: new Date(data.start_time).toISOString(),
546 endTime: data.end_time ? new Date(data.end_time).toISOString() : null };
547 const start = new Date(data.start_time);
548 if (isNaN(start.getTime())) {
549 return { startTime: new Date(data.start_time).toISOString(),
550 endTime: data.end_time ? new Date(data.end_time).toISOString() : null };
551 }
552 const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0, 0);
553 // End: if user gave an end date, snap to midnight after that day; else single-day event.
554 let endDay;
555 if (data.end_time) {
556 const end = new Date(data.end_time);
557 if (!isNaN(end.getTime())) {
558 endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1, 0, 0, 0, 0);
559 }
560 }
561 if (!endDay) {
562 endDay = new Date(startDay.getFullYear(), startDay.getMonth(), startDay.getDate() + 1, 0, 0, 0, 0);
563 }
564 return { startTime: startDay.toISOString(), endTime: endDay.toISOString() };
565 }
566
567 async function create(data) {
568 const form = document.querySelector('.modal-content form');
569 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null;
570 const { startTime, endTime } = _normalizeForAllDay(data);
571 const input = {
572 title: data.title,
573 description: data.description || '',
574 projectId: data.project_id || null,
575 startTime,
576 endTime,
577 location: data.location || null,
578 recurrence: data.recurrence || 'None',
579 recurrenceRule,
580 contactId: data.contact_id || null,
581 blockType: data.block_type || null,
582 reminderOffsetsSeconds: collectReminderOffsets(form),
583 };
584
585 const reloadFns = [load];
586 const currentProjectId = GoingsOn.getCurrentProjectId();
587 if (currentProjectId) {
588 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
589 }
590
591 GoingsOn.cache.invalidate('events');
592 await GoingsOn.ui.apiCall(GoingsOn.api.events.create(input), {
593 successMessage: 'Event created!',
594 errorMessage: 'Failed to create event',
595 reload: reloadFns,
596 });
597 }
598
599 /**
600 * Open the event detail modal with edit and delete actions.
601 * @param {string} id - Event ID to open
602 */
603 async function open(id) {
604 try {
605 const event = await GoingsOn.api.events.get(id);
606 if (!event) return;
607
608 const snoozeUntilLabel = event.isSnoozed && event.snoozedUntil
609 ? GoingsOn.snooze.formatTime(event.snoozedUntil)
610 : null;
611 const snoozeButton = event.isSnoozed
612 ? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('event', '${escAttr(id)}')">Unsnooze</button>`
613 : `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('event', '${escAttr(id)}')">Snooze</button>`;
614 const snoozeStatus = snoozeUntilLabel
615 ? `<p><strong>Snoozed until:</strong> ${esc(snoozeUntilLabel)}</p>`
616 : '';
617 const reminders = event.reminderOffsetsSeconds || [];
618 const reminderLabels = reminders
619 .map(s => REMINDER_PRESETS.find(p => p.seconds === s)?.label || `${s} seconds before`)
620 .join(', ');
621 const reminderStatus = reminders.length
622 ? `<p><strong>Reminders:</strong> ${esc(reminderLabels)}</p>`
623 : '';
624 const content = `
625 <div style="margin-bottom: 1rem;">
626 <h3>${esc(event.title)}</h3>
627 <div class="markdown-content">${event.descriptionHtml || ''}</div>
628 <p><strong>When:</strong> ${event.timeFormatted}</p>
629 ${event.location ? `<p><strong>Where:</strong> ${esc(event.location)}</p>` : ''}
630 ${snoozeStatus}
631 ${reminderStatus}
632 </div>
633 <div class="form-actions">
634 <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.events.delete('${escAttr(id)}')">Delete</button>
635 <div class="form-actions-spacer"></div>
636 ${snoozeButton}
637 <button class="btn btn-secondary" onclick="GoingsOn.events.openEdit('${escAttr(id)}')">Edit</button>
638 <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
639 </div>
640 `;
641 GoingsOn.ui.openModal('Event Details', content);
642 } catch (err) {
643 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load event'), 'error');
644 }
645 }
646
647 /**
648 * Delete an event with confirmation and undo support.
649 * @param {string} id - Event ID to delete
650 */
651 /**
652 * For recurring events, surface the scope of the change before any edit/delete.
653 * Per-occurrence overrides aren't supported by the backend yet — every edit
654 * applies to the whole series. This dialog at least makes that explicit so
655 * users don't silently cascade a change across all instances.
656 * (Phase 4 #7 / Phase 7 Tier 1 #5.)
657 *
658 * @param {Object} event - Fetched event record
659 * @param {string} action - 'edit' or 'delete' (verb for the prompt)
660 * @returns {Promise<boolean>} true if the user confirmed, false if cancelled
661 */
662 async function confirmRecurringScope(event, action) {
663 if (!event) return true;
664 const isTemplate = !!(event.recurrence && event.recurrence !== 'None' && !event.isRecurringInstance);
665 const isInstance = !!event.isRecurringInstance;
666 if (!isTemplate && !isInstance) return true;
667
668 const pattern = event.recurrence || 'recurring';
669 const verb = action === 'delete' ? 'Delete' : 'Edit';
670 const past = action === 'delete' ? 'deleted' : 'edited';
671 return GoingsOn.ui.showConfirmDialog(
672 `${verb} recurring event`,
673 `This event repeats (${pattern}). The whole series will be ${past} — per-occurrence overrides aren't supported yet.`,
674 { confirmText: `${verb} entire series`, cancelText: 'Cancel', danger: action === 'delete' }
675 );
676 }
677
678 async function deleteEvent(id) {
679 let eventRecord;
680 try { eventRecord = await GoingsOn.api.events.get(id); } catch (_) { /* fetch failed; fall through */ }
681 const isRecurring = !!(eventRecord && eventRecord.recurrence && eventRecord.recurrence !== 'None');
682 if (isRecurring) {
683 // Recurring scope warning replaces the standard confirm dialog.
684 if (!(await confirmRecurringScope(eventRecord, 'delete'))) return;
685 } else {
686 if (!await GoingsOn.ui.confirmDelete('event')) return;
687 }
688
689 GoingsOn.cache.invalidate('events');
690 const upcoming = GoingsOn.state.upcomingEvents || [];
691 const past = GoingsOn.state.pastEvents || [];
692 const removedUpcoming = upcoming.find(e => e.id === id);
693 const removedPast = past.find(e => e.id === id);
694 const removedEvent = removedUpcoming || removedPast;
695 if (removedUpcoming) {
696 GoingsOn.state.set('upcomingEvents', upcoming.filter(e => e.id !== id));
697 }
698 if (removedPast) {
699 GoingsOn.state.set('pastEvents', past.filter(e => e.id !== id));
700 }
701
702 GoingsOn.ui.showUndoToast('Event deleted', {
703 onConfirm: async () => {
704 try {
705 await GoingsOn.api.events.delete(id);
706 } catch (err) {
707 GoingsOn.ui.showToast('Failed to delete event', 'error');
708 load();
709 }
710 },
711 onUndo: () => {
712 if (removedEvent) {
713 if (removedUpcoming) {
714 GoingsOn.state.set('upcomingEvents', [...(GoingsOn.state.upcomingEvents || []), removedEvent]);
715 } else {
716 GoingsOn.state.set('pastEvents', [...(GoingsOn.state.pastEvents || []), removedEvent]);
717 }
718 }
719 },
720 });
721 }
722
723 /**
724 * Fetch an event and open the edit form modal.
725 * @param {string} id - Event ID to edit
726 */
727 async function openEdit(id) {
728 try {
729 const event = await GoingsOn.api.events.get(id);
730 if (!event) {
731 GoingsOn.ui.showToast('Event not found', 'error');
732 return;
733 }
734
735 // For recurring events, surface the scope of the change before
736 // opening the form. (Phase 4 #7 / Phase 7 Tier 1 #5.)
737 if (!(await confirmRecurringScope(event, 'edit'))) return;
738
739 GoingsOn.ui.openFormModal({
740 title: 'Edit Event',
741 entityType: 'event',
742 isEdit: true,
743 entityId: id,
744 fields: getEventFormFields(event),
745 extraContent: buildRemindersHtml(event),
746 onSubmit: (data) => update(id, data),
747 });
748 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
749 } catch (err) {
750 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load event'), 'error');
751 }
752 }
753
754 /**
755 * Update an existing event from form data.
756 * @param {string} id - Event ID to update
757 * @param {Object} data - Form data with title, description, start_time, etc.
758 */
759 async function update(id, data) {
760 const form = document.querySelector('.modal-content form');
761 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null;
762 const { startTime, endTime } = _normalizeForAllDay(data);
763 const input = {
764 title: data.title,
765 description: data.description || '',
766 startTime,
767 endTime,
768 location: data.location || null,
769 recurrence: data.recurrence || 'None',
770 recurrenceRule,
771 contactId: data.contact_id || null,
772 blockType: data.block_type || null,
773 reminderOffsetsSeconds: collectReminderOffsets(form),
774 };
775
776 GoingsOn.cache.invalidate('events');
777 await GoingsOn.ui.apiCall(GoingsOn.api.events.update(id, input), {
778 successMessage: 'Event updated!',
779 errorMessage: 'Failed to update event',
780 reload: load,
781 });
782 }
783
784 // ============ Event Status Indicator ============
785
786 let statusPollInterval = null;
787
788 /**
789 * Fetches the aggregate event status indicator from the backend
790 * and applies it to the UI status dots. All date math is done in Rust.
791 */
792 async function updateEventStatusDot() {
793 const leadMinutes = parseInt(localStorage.getItem('goingson-event-lead-minutes') || '15', 10);
794 let status, label;
795
796 try {
797 const indicator = await GoingsOn.api.events.getStatusIndicator(leadMinutes);
798 status = indicator.status;
799 label = indicator.label;
800 } catch {
801 // Fallback if backend unavailable
802 status = 'none';
803 label = 'Status unavailable';
804 }
805
806 // Desktop: dot on the tab
807 const tab = document.querySelector('.tab[data-view="events"]');
808 if (tab) {
809 let dot = tab.querySelector('.tab-status-dot');
810 if (!dot) {
811 dot = document.createElement('span');
812 dot.className = 'tab-status-dot';
813 tab.appendChild(dot);
814 }
815 dot.className = 'tab-status-dot status-' + status;
816 dot.setAttribute('aria-label', label);
817 }
818
819 }
820
821 function startEventStatusPolling() {
822 updateEventStatusDot();
823 if (statusPollInterval) clearInterval(statusPollInterval);
824 statusPollInterval = setInterval(updateEventStatusDot, 30000);
825 }
826
827 // ============ Populate GoingsOn.events Namespace ============
828
829 GoingsOn.events = {
830 load,
831 onFilterChange,
832 openNew,
833 openNewForProject,
834 create,
835 open,
836 delete: deleteEvent,
837 openEdit,
838 update,
839 // Bulk operations
840 toggleEventSelection,
841 selectAllEvents,
842 clearEventSelection,
843 bulkDelete: bulkDeleteEvents,
844 // Event status indicator
845 updateEventStatusDot,
846 startEventStatusPolling,
847 // Expose form fields for potential reuse
848 getFormFields: getEventFormFields,
849 // Virtual scrolling helpers
850 renderEventRow,
851 getUpcomingScroller: () => upcomingEventsScroller,
852 getPastScroller: () => pastEventsScroller,
853 };
854
855 })();
856