Skip to main content

max / goingson

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