Skip to main content

max / goingson

36.4 KB · 861 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 = `
323 <div class="empty-state">
324 <p class="empty-state-text">No events scheduled.</p>
325 <button class="btn btn-primary empty-state-action" onclick="GoingsOn.events.openNew()">Add Event</button>
326 </div>
327 `;
328 [upcomingEventsScroller, pastEventsScroller, recurringEventsScroller].forEach(s => s && s.destroy());
329 upcomingEventsScroller = pastEventsScroller = recurringEventsScroller = null;
330 return;
331 }
332
333 // Events come pre-sorted by start_time ASC from backend.
334 events = events.map(e => ({ ...e, displayTitle: e.title }));
335
336 // Recurring section shows TEMPLATES only (the parent rule), not each expanded
337 // occurrence — so users can find the rule itself without scrolling past 50
338 // weekly instances. Templates are identified by recurrence !== 'None' and
339 // !isRecurringInstance (the parent row, not a generated copy).
340 const recurring = events.filter(e => e.recurrence && e.recurrence !== 'None' && !e.isRecurringInstance);
341 const nonTemplate = events.filter(e => !(e.recurrence && e.recurrence !== 'None' && !e.isRecurringInstance));
342
343 GoingsOn.state.set('recurringEvents', recurring);
344 GoingsOn.state.set('upcomingEvents', nonTemplate.filter(e => !e.isPast));
345 GoingsOn.state.set('pastEvents', nonTemplate.filter(e => e.isPast).reverse());
346
347 // --- Recurring (top) ---
348 if (recurring.length > 0) {
349 recurringSection.classList.remove('hidden');
350 recurringCount.textContent = recurring.length;
351 if (!recurringEventsScroller) {
352 recurringEventsScroller = new GoingsOn.VirtualScroller({
353 container: recurringContainer,
354 renderItem: (e, i) => renderEventRow(e, i, false, true),
355 getItems: () => GoingsOn.state.recurringEvents,
356 rowHeight: { estimated: 52, measure: true },
357 overscan: 3,
358 });
359 } else {
360 recurringEventsScroller.refresh();
361 }
362 } else {
363 recurringSection.classList.add('hidden');
364 if (recurringEventsScroller) {
365 recurringEventsScroller.destroy();
366 recurringEventsScroller = null;
367 }
368 }
369
370 // --- Upcoming (middle) ---
371 if (GoingsOn.state.upcomingEvents.length > 0) {
372 eventTable.style.display = 'flex';
373 if (futureHeading) futureHeading.classList.remove('hidden');
374 if (!upcomingEventsScroller) {
375 upcomingEventsScroller = new GoingsOn.VirtualScroller({
376 container: upcomingContainer,
377 renderItem: renderEventRow,
378 getItems: () => GoingsOn.state.upcomingEvents,
379 rowHeight: { estimated: 52, measure: true },
380 overscan: 5,
381 });
382 } else {
383 upcomingEventsScroller.refresh();
384 }
385 } else {
386 eventTable.style.display = 'none';
387 if (futureHeading) futureHeading.classList.add('hidden');
388 upcomingContainer.innerHTML = '<div class="loading">No upcoming events</div>';
389 if (upcomingEventsScroller) {
390 upcomingEventsScroller.destroy();
391 upcomingEventsScroller = null;
392 }
393 }
394
395 // --- Past (bottom) ---
396 if (GoingsOn.state.pastEvents.length > 0) {
397 pastSection.classList.remove('hidden');
398 pastCount.textContent = GoingsOn.state.pastEvents.length;
399 if (!pastEventsScroller) {
400 pastEventsScroller = new GoingsOn.VirtualScroller({
401 container: pastContainer,
402 renderItem: (e, i) => renderEventRow(e, i, true),
403 getItems: () => GoingsOn.state.pastEvents,
404 rowHeight: { estimated: 52, measure: true },
405 overscan: 3,
406 });
407 } else {
408 pastEventsScroller.refresh();
409 }
410 } else {
411 pastSection.classList.add('hidden');
412 if (pastEventsScroller) {
413 pastEventsScroller.destroy();
414 pastEventsScroller = null;
415 }
416 }
417 GoingsOn.cache.markLoaded('events');
418 } catch (err) {
419 upcomingContainer.innerHTML = `<div class="error-state error-state--padded">Failed to load events</div>`;
420 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load events'), 'error', {
421 action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('events'); load(); } },
422 duration: 8000,
423 });
424 }
425 }
426
427 /**
428 * Render a single event row as a div (for virtual scrolling).
429 * @param {Object} e - Event object
430 * @param {number} index - Event index
431 * @param {boolean} isPast - Whether this is a past event
432 * @returns {string} HTML string
433 */
434 function renderEventRow(e, index, isPast = false, isRecurring = false) {
435 const displayTitle = e.displayTitle || e.title;
436 const startDate = new Date(e.startTime);
437 const monthName = startDate.toLocaleDateString('en-US', { month: 'short' });
438
439 // Recurring template row: replace the date cell with the recurrence pattern label
440 // so users see "Weekly · Mon Wed Fri" rather than a single arbitrary start date.
441 if (isRecurring) {
442 const patternLabel = e.recurrenceDisplay || e.recurrence || 'Recurring';
443 return `
444 <div class="event-row-virtual event-recurring"
445 data-id="${escAttr(e.id)}"
446 onclick="GoingsOn.events.open('${escAttr(e.id)}')"
447 oncontextmenu="GoingsOn.contextMenus.showEvent(event, '${escAttr(e.id)}')"
448 tabindex="0" role="row">
449 <div class="event-cell event-cell-date"><span class="event-recurrence-pattern">${esc(patternLabel)}</span></div>
450 <div class="event-cell event-cell-time">${e.timeFormatted}</div>
451 <div class="event-cell event-cell-title">${esc(displayTitle)}</div>
452 <div class="event-cell event-cell-location">${e.location ? esc(e.location) : '-'}</div>
453 <div class="event-cell" style="text-align: right;" onclick="event.stopPropagation();">
454 <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEvent(event, '${escAttr(e.id)}')" title="Actions" aria-label="Event actions">&#x22EE;</button>
455 </div>
456 </div>
457 `;
458 }
459
460 // Mobile: insert date group header when date changes from previous item
461 let dateHeader = '';
462 if (GoingsOn.touch?.isTouchDevice && index > 0) {
463 const items = isPast ? (GoingsOn.state.pastEvents || []) : (GoingsOn.state.upcomingEvents || []);
464 const prevItem = items[index - 1];
465 if (prevItem) {
466 const prevDate = new Date(prevItem.startTime).toDateString();
467 const curDate = startDate.toDateString();
468 if (prevDate !== curDate) {
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 } else if (GoingsOn.touch?.isTouchDevice && index === 0) {
474 const dayName = startDate.toLocaleDateString('en-US', { weekday: 'long' });
475 dateHeader = `<div class="event-date-group-header">${dayName}, ${startDate.getDate()} ${monthName}</div>`;
476 }
477
478 return `
479 ${dateHeader}
480 <div class="event-row-virtual ${e.isPast || isPast ? 'event-past' : ''}"
481 data-id="${escAttr(e.id)}"
482 onclick="GoingsOn.events.open('${escAttr(e.id)}')"
483 oncontextmenu="GoingsOn.contextMenus.showEvent(event, '${escAttr(e.id)}')"
484 tabindex="0" role="row">
485 <div class="event-cell event-cell--shrink">
486 <input type="checkbox" class="bulk-checkbox event-select-cb" data-id="${escAttr(e.id)}"
487 onclick="event.stopPropagation(); GoingsOn.events.toggleEventSelection('${escAttr(e.id)}', event)"
488 aria-label="Select event">
489 </div>
490 <div class="event-cell event-cell-date">
491 <span class="event-date-num">${startDate.getDate()} ${monthName}</span>
492 <span class="event-date-badge event-proximity-${e.proximityClass || 'default'}">${e.proximityLabel || ''}</span>
493 </div>
494 <div class="event-cell event-cell-time">${e.timeFormatted}</div>
495 <div class="event-cell event-cell-title">${esc(displayTitle)}</div>
496 <div class="event-cell event-cell-location">${e.location ? esc(e.location) : '-'}</div>
497 <div class="event-cell" style="text-align: right;" onclick="event.stopPropagation();">
498 <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEvent(event, '${escAttr(e.id)}')" title="Actions" aria-label="Event actions">&#x22EE;</button>
499 </div>
500 </div>
501 `;
502 }
503
504 function openNew() {
505 GoingsOn.ui.openFormModal({
506 title: 'New Event',
507 entityType: 'event',
508 isEdit: false,
509 fields: getEventFormFields(),
510 extraContent: buildRemindersHtml(null),
511 onSubmit: create,
512 });
513 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
514 }
515
516 /**
517 * Open the new event form modal pre-filled for a specific project.
518 * @param {string} projectId - Project to pre-select in the form
519 */
520 function openNewForProject(projectId) {
521 const project = GoingsOn.getProjectsCache().find(p => p.id === projectId);
522
523 GoingsOn.ui.openFormModal({
524 title: 'New Event',
525 entityType: 'event',
526 isEdit: false,
527 fields: getEventFormFields(null, projectId),
528 presetData: { project_id: projectId },
529 onSubmit: create,
530 extraContent: (project ? `
531 <div class="form-group">
532 <label class="form-label">Project</label>
533 <input type="text" class="form-input" value="${esc(project.name)}" disabled>
534 </div>
535 ` : '') + buildRemindersHtml(null),
536 });
537 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
538 }
539
540 /**
541 * Create a new event from form data.
542 * @param {Object} data - Form data with title, description, start_time, end_time, location, etc.
543 */
544 /**
545 * Phase 7 Tier 5 — snap start/end to midnight pair when "All day" is set.
546 * The backend doesn't have a dedicated all-day flag, so we author the
547 * canonical 00:00 → next-day-00:00 shape the calendar renderer detects.
548 */
549 function _normalizeForAllDay(data) {
550 if (!data.is_all_day) return { startTime: new Date(data.start_time).toISOString(),
551 endTime: data.end_time ? new Date(data.end_time).toISOString() : null };
552 const start = new Date(data.start_time);
553 if (isNaN(start.getTime())) {
554 return { startTime: new Date(data.start_time).toISOString(),
555 endTime: data.end_time ? new Date(data.end_time).toISOString() : null };
556 }
557 const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0, 0);
558 // End: if user gave an end date, snap to midnight after that day; else single-day event.
559 let endDay;
560 if (data.end_time) {
561 const end = new Date(data.end_time);
562 if (!isNaN(end.getTime())) {
563 endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1, 0, 0, 0, 0);
564 }
565 }
566 if (!endDay) {
567 endDay = new Date(startDay.getFullYear(), startDay.getMonth(), startDay.getDate() + 1, 0, 0, 0, 0);
568 }
569 return { startTime: startDay.toISOString(), endTime: endDay.toISOString() };
570 }
571
572 async function create(data) {
573 const form = document.querySelector('.modal-content form');
574 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null;
575 const { startTime, endTime } = _normalizeForAllDay(data);
576 const input = {
577 title: data.title,
578 description: data.description || '',
579 projectId: data.project_id || null,
580 startTime,
581 endTime,
582 location: data.location || null,
583 recurrence: data.recurrence || 'None',
584 recurrenceRule,
585 contactId: data.contact_id || null,
586 blockType: data.block_type || null,
587 reminderOffsetsSeconds: collectReminderOffsets(form),
588 };
589
590 const reloadFns = [load];
591 const currentProjectId = GoingsOn.getCurrentProjectId();
592 if (currentProjectId) {
593 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
594 }
595
596 GoingsOn.cache.invalidate('events');
597 await GoingsOn.ui.apiCall(GoingsOn.api.events.create(input), {
598 successMessage: 'Event created!',
599 errorMessage: 'Failed to create event',
600 reload: reloadFns,
601 });
602 }
603
604 /**
605 * Open the event detail modal with edit and delete actions.
606 * @param {string} id - Event ID to open
607 */
608 async function open(id) {
609 try {
610 const event = await GoingsOn.api.events.get(id);
611 if (!event) return;
612
613 const snoozeUntilLabel = event.isSnoozed && event.snoozedUntil
614 ? GoingsOn.snooze.formatTime(event.snoozedUntil)
615 : null;
616 const snoozeButton = event.isSnoozed
617 ? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('event', '${escAttr(id)}')">Unsnooze</button>`
618 : `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('event', '${escAttr(id)}')">Snooze</button>`;
619 const snoozeStatus = snoozeUntilLabel
620 ? `<p><strong>Snoozed until:</strong> ${esc(snoozeUntilLabel)}</p>`
621 : '';
622 const reminders = event.reminderOffsetsSeconds || [];
623 const reminderLabels = reminders
624 .map(s => REMINDER_PRESETS.find(p => p.seconds === s)?.label || `${s} seconds before`)
625 .join(', ');
626 const reminderStatus = reminders.length
627 ? `<p><strong>Reminders:</strong> ${esc(reminderLabels)}</p>`
628 : '';
629 const content = `
630 <div style="margin-bottom: 1rem;">
631 <h3>${esc(event.title)}</h3>
632 <div class="markdown-content">${event.descriptionHtml || ''}</div>
633 <p><strong>When:</strong> ${event.timeFormatted}</p>
634 ${event.location ? `<p><strong>Where:</strong> ${esc(event.location)}</p>` : ''}
635 ${snoozeStatus}
636 ${reminderStatus}
637 </div>
638 <div class="form-actions">
639 <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.events.delete('${escAttr(id)}')">Delete</button>
640 <div class="form-actions-spacer"></div>
641 ${snoozeButton}
642 <button class="btn btn-secondary" onclick="GoingsOn.events.openEdit('${escAttr(id)}')">Edit</button>
643 <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
644 </div>
645 `;
646 GoingsOn.ui.openModal('Event Details', content);
647 } catch (err) {
648 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load event'), 'error');
649 }
650 }
651
652 /**
653 * Delete an event with confirmation and undo support.
654 * @param {string} id - Event ID to delete
655 */
656 /**
657 * For recurring events, surface the scope of the change before any edit/delete.
658 * Per-occurrence overrides aren't supported by the backend yet — every edit
659 * applies to the whole series. This dialog at least makes that explicit so
660 * users don't silently cascade a change across all instances.
661 * (Phase 4 #7 / Phase 7 Tier 1 #5.)
662 *
663 * @param {Object} event - Fetched event record
664 * @param {string} action - 'edit' or 'delete' (verb for the prompt)
665 * @returns {Promise<boolean>} true if the user confirmed, false if cancelled
666 */
667 async function confirmRecurringScope(event, action) {
668 if (!event) return true;
669 const isTemplate = !!(event.recurrence && event.recurrence !== 'None' && !event.isRecurringInstance);
670 const isInstance = !!event.isRecurringInstance;
671 if (!isTemplate && !isInstance) return true;
672
673 const pattern = event.recurrence || 'recurring';
674 const verb = action === 'delete' ? 'Delete' : 'Edit';
675 const past = action === 'delete' ? 'deleted' : 'edited';
676 return GoingsOn.ui.showConfirmDialog(
677 `${verb} recurring event`,
678 `This event repeats (${pattern}). The whole series will be ${past} — per-occurrence overrides aren't supported yet.`,
679 { confirmText: `${verb} entire series`, cancelText: 'Cancel', danger: action === 'delete' }
680 );
681 }
682
683 async function deleteEvent(id) {
684 let eventRecord;
685 try { eventRecord = await GoingsOn.api.events.get(id); } catch (_) { /* fetch failed; fall through */ }
686 const isRecurring = !!(eventRecord && eventRecord.recurrence && eventRecord.recurrence !== 'None');
687 if (isRecurring) {
688 // Recurring scope warning replaces the standard confirm dialog.
689 if (!(await confirmRecurringScope(eventRecord, 'delete'))) return;
690 } else {
691 if (!await GoingsOn.ui.confirmDelete('event')) return;
692 }
693
694 GoingsOn.cache.invalidate('events');
695 const upcoming = GoingsOn.state.upcomingEvents || [];
696 const past = GoingsOn.state.pastEvents || [];
697 const removedUpcoming = upcoming.find(e => e.id === id);
698 const removedPast = past.find(e => e.id === id);
699 const removedEvent = removedUpcoming || removedPast;
700 if (removedUpcoming) {
701 GoingsOn.state.set('upcomingEvents', upcoming.filter(e => e.id !== id));
702 }
703 if (removedPast) {
704 GoingsOn.state.set('pastEvents', past.filter(e => e.id !== id));
705 }
706
707 GoingsOn.ui.showUndoToast('Event deleted', {
708 onConfirm: async () => {
709 try {
710 await GoingsOn.api.events.delete(id);
711 } catch (err) {
712 GoingsOn.ui.showToast('Failed to delete event', 'error');
713 load();
714 }
715 },
716 onUndo: () => {
717 if (removedEvent) {
718 if (removedUpcoming) {
719 GoingsOn.state.set('upcomingEvents', [...(GoingsOn.state.upcomingEvents || []), removedEvent]);
720 } else {
721 GoingsOn.state.set('pastEvents', [...(GoingsOn.state.pastEvents || []), removedEvent]);
722 }
723 }
724 },
725 });
726 }
727
728 /**
729 * Fetch an event and open the edit form modal.
730 * @param {string} id - Event ID to edit
731 */
732 async function openEdit(id) {
733 try {
734 const event = await GoingsOn.api.events.get(id);
735 if (!event) {
736 GoingsOn.ui.showToast('Event not found', 'error');
737 return;
738 }
739
740 // For recurring events, surface the scope of the change before
741 // opening the form. (Phase 4 #7 / Phase 7 Tier 1 #5.)
742 if (!(await confirmRecurringScope(event, 'edit'))) return;
743
744 GoingsOn.ui.openFormModal({
745 title: 'Edit Event',
746 entityType: 'event',
747 isEdit: true,
748 entityId: id,
749 fields: getEventFormFields(event),
750 extraContent: buildRemindersHtml(event),
751 onSubmit: (data) => update(id, data),
752 });
753 GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence');
754 } catch (err) {
755 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load event'), 'error');
756 }
757 }
758
759 /**
760 * Update an existing event from form data.
761 * @param {string} id - Event ID to update
762 * @param {Object} data - Form data with title, description, start_time, etc.
763 */
764 async function update(id, data) {
765 const form = document.querySelector('.modal-content form');
766 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null;
767 const { startTime, endTime } = _normalizeForAllDay(data);
768 const input = {
769 title: data.title,
770 description: data.description || '',
771 startTime,
772 endTime,
773 location: data.location || null,
774 recurrence: data.recurrence || 'None',
775 recurrenceRule,
776 contactId: data.contact_id || null,
777 blockType: data.block_type || null,
778 reminderOffsetsSeconds: collectReminderOffsets(form),
779 };
780
781 GoingsOn.cache.invalidate('events');
782 await GoingsOn.ui.apiCall(GoingsOn.api.events.update(id, input), {
783 successMessage: 'Event updated!',
784 errorMessage: 'Failed to update event',
785 reload: load,
786 });
787 }
788
789 // ============ Event Status Indicator ============
790
791 let statusPollInterval = null;
792
793 /**
794 * Fetches the aggregate event status indicator from the backend
795 * and applies it to the UI status dots. All date math is done in Rust.
796 */
797 async function updateEventStatusDot() {
798 const leadMinutes = parseInt(localStorage.getItem('goingson-event-lead-minutes') || '15', 10);
799 let status, label;
800
801 try {
802 const indicator = await GoingsOn.api.events.getStatusIndicator(leadMinutes);
803 status = indicator.status;
804 label = indicator.label;
805 } catch {
806 // Fallback if backend unavailable
807 status = 'none';
808 label = 'Status unavailable';
809 }
810
811 // Desktop: dot on the tab
812 const tab = document.querySelector('.tab[data-view="events"]');
813 if (tab) {
814 let dot = tab.querySelector('.tab-status-dot');
815 if (!dot) {
816 dot = document.createElement('span');
817 dot.className = 'tab-status-dot';
818 tab.appendChild(dot);
819 }
820 dot.className = 'tab-status-dot status-' + status;
821 dot.setAttribute('aria-label', label);
822 }
823
824 }
825
826 function startEventStatusPolling() {
827 updateEventStatusDot();
828 if (statusPollInterval) clearInterval(statusPollInterval);
829 statusPollInterval = setInterval(updateEventStatusDot, 30000);
830 }
831
832 // ============ Populate GoingsOn.events Namespace ============
833
834 GoingsOn.events = {
835 load,
836 onFilterChange,
837 openNew,
838 openNewForProject,
839 create,
840 open,
841 delete: deleteEvent,
842 openEdit,
843 update,
844 // Bulk operations
845 toggleEventSelection,
846 selectAllEvents,
847 clearEventSelection,
848 bulkDelete: bulkDeleteEvents,
849 // Event status indicator
850 updateEventStatusDot,
851 startEventStatusPolling,
852 // Expose form fields for potential reuse
853 getFormFields: getEventFormFields,
854 // Virtual scrolling helpers
855 renderEventRow,
856 getUpcomingScroller: () => upcomingEventsScroller,
857 getPastScroller: () => pastEventsScroller,
858 };
859
860 })();
861