/** * GoingsOn - Events Module * Event list, CRUD, rendering */ // ============ Events Module ============ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; // ============ Reminder Presets ============ // Common lead times users actually want; renders as a checkbox group in // the event form. Seconds-before-start_time, matching the backend column. const REMINDER_PRESETS = [ { seconds: 0, label: 'At time of event' }, { seconds: 300, label: '5 minutes before' }, { seconds: 900, label: '15 minutes before' }, { seconds: 1800, label: '30 minutes before' }, { seconds: 3600, label: '1 hour before' }, { seconds: 86400, label: '1 day before' }, ]; function buildRemindersHtml(event) { const selected = new Set((event?.reminderOffsetsSeconds || []).map(Number)); const items = REMINDER_PRESETS.map(p => ` `).join(''); return `
Desktop notifications fire at the chosen lead times.
${items}
`; } function collectReminderOffsets(form) { if (!form) return []; return REMINDER_PRESETS .filter(p => form.elements[`reminder_offset_${p.seconds}`]?.checked) .map(p => p.seconds); } // ============ Virtual Scroller Instances ============ let upcomingEventsScroller = null; let pastEventsScroller = null; let recurringEventsScroller = null; // ============ Selection State ============ const selectedEventIds = new Set(); function toggleEventSelection(id, event) { if (event) event.stopPropagation(); if (selectedEventIds.has(id)) { selectedEventIds.delete(id); } else { selectedEventIds.add(id); } updateEventSelectionUI(); } function selectAllEvents() { const events = [ ...(GoingsOn.state.upcomingEvents || []), ...(GoingsOn.state.pastEvents || []), ]; events.forEach(e => selectedEventIds.add(e.id)); updateEventSelectionUI(); } function clearEventSelection() { selectedEventIds.clear(); updateEventSelectionUI(); } function updateEventSelectionUI() { document.querySelectorAll('.event-select-cb').forEach(cb => { cb.checked = selectedEventIds.has(cb.dataset.id); }); const bar = document.getElementById('events-bulk-bar'); if (bar) { bar.classList.toggle('hidden', selectedEventIds.size === 0); const count = bar.querySelector('.bulk-count'); if (count) count.textContent = `${selectedEventIds.size} selected`; } } async function bulkDeleteEvents() { const count = selectedEventIds.size; if (count === 0) return; if (!await GoingsOn.ui.confirmDelete(`${count} event${count > 1 ? 's' : ''}`)) return; const ids = [...selectedEventIds]; GoingsOn.cache.invalidate('events'); try { await GoingsOn.api.events.bulkDelete(ids); selectedEventIds.clear(); GoingsOn.ui.showToast(`${count} event${count > 1 ? 's' : ''} deleted`, 'success'); load(); } catch (err) { GoingsOn.ui.showToast('Failed to delete events: ' + GoingsOn.utils.getErrorMessage(err), 'error'); } } // ============ Form Field Definitions ============ /** * Build form field definitions for the event create/edit modal. * @param {Object|null} event - Existing event for edit mode, or null for create * @param {string|null} projectId - Pre-selected project ID, or null * @returns {FormField[]} Array of form field definitions */ /** * Phase 7 Tier 5 — detect an existing all-day event so the form's * "All day" checkbox pre-checks. Matches the calendar renderer's heuristic * (duration ≥ 23 h) and also catches the canonical 00:00 → next-day-00:00 * shape we author below. */ function _isAllDayEvent(event) { if (!event?.start_time) return false; const start = new Date(event.start_time); const end = event.end_time ? new Date(event.end_time) : null; if (!end) return false; const durHours = (end - start) / (1000 * 60 * 60); const startsAtMidnight = start.getHours() === 0 && start.getMinutes() === 0; return durHours >= 23 && startsAtMidnight; } function getEventFormFields(event = null, projectId = null) { const now = new Date(); const localISOTime = GoingsOn.utils.toLocalISOString(now); const isAllDay = _isAllDayEvent(event); const fields = [ { name: 'is_all_day', type: 'checkbox', label: 'All day', value: isAllDay, hint: 'Removes the time component — the event spans the whole day.', }, { name: 'title', type: 'text', label: 'Title', placeholder: 'Event title', required: true, value: event?.title || '', validate: (v) => v && v.length > 200 ? 'Maximum 200 characters' : null, }, { name: 'description', type: 'textarea', label: 'Description', placeholder: 'Event details...', value: event?.description || '', validate: (v) => v && v.length > 2000 ? 'Maximum 2000 characters' : null, }, { name: 'start_time', type: 'text', label: 'Start Date & Time', placeholder: 'tomorrow 3pm, friday 10:00, 2026-12-25...', required: true, value: event?.start_time ? new Date(event.start_time).toISOString().slice(0, 16) : localISOTime, transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview, }, { name: 'end_time', type: 'text', label: 'End Time (optional)', placeholder: 'tomorrow 5pm, friday 12:00...', value: event?.end_time ? new Date(event.end_time).toISOString().slice(0, 16) : '', transform: (v) => GoingsOn.utils.parseNaturalDate(v) || v, onInput: GoingsOn.utils.dateParsePreview, validate: (v, data) => { if (v && data.start_time && new Date(v) < new Date(data.start_time)) { return 'End time must be after start time'; } return null; }, }, { name: 'location', type: 'text', label: 'Location', placeholder: 'Zoom / Office / Coffee Shop', value: event?.location || '', validate: (v) => v && v.length > 200 ? 'Maximum 200 characters' : null, }, ]; // Recurrence select field const RECURRENCE_OPTIONS = GoingsOn.taskForms.RECURRENCE_OPTIONS; fields.push({ name: 'recurrence', type: 'select', label: 'Recurrence', hint: 'Recurring events appear automatically on matching days', hintExtraHtml: GoingsOn.taskForms.buildRecurrenceConfigHtml(event?.recurrenceRule, 'event'), options: RECURRENCE_OPTIONS.map(r => ({ ...r, selected: r.value === (event?.recurrence || 'None'), })), value: event?.recurrence || 'None', }); // Block type select field fields.push({ name: 'block_type', type: 'select', label: 'Type', options: [ { value: '', label: 'Regular Event' }, { value: 'free_time', label: 'Free Time' }, { value: 'personal', label: 'Personal' }, { value: 'vacation', label: 'Vacation' }, { value: 'focus', label: 'Focus' }, ], value: event?.blockType || '', }); // Contact select field fields.push({ name: 'contact_id', type: 'select', label: 'Contact', options: [ { value: '', label: 'No Contact' }, ...(GoingsOn.state.contacts || []).map(c => ({ value: c.id, label: c.displayName || c.display_name, selected: c.id === event?.contactId, })), ], value: event?.contactId || '', }); // Add hidden project_id if specified if (projectId) { fields.unshift({ name: 'project_id', type: 'hidden', value: projectId, }); } return fields; } // ============ Core Functions ============ // Events stored in centralized state for virtual scrolling GoingsOn.state.set('upcomingEvents', []); GoingsOn.state.set('pastEvents', []); GoingsOn.state.set('recurringEvents', []); /** * Fetch all events and render the segmented list: Recurring (templates only) at top, * Upcoming in the middle, Past (collapsed) at the bottom. Recurring instances are * shown in Upcoming/Past based on their occurrence date. */ /** * Re-fetch events after a filter checkbox change. Invalidates the * view cache so load() doesn't short-circuit on the freshness check. */ function onFilterChange() { GoingsOn.cache.invalidate('events'); load(); } async function load() { if (GoingsOn.cache.isFresh('events')) return; const upcomingContainer = document.getElementById('event-list-container'); const pastContainer = document.getElementById('past-event-list-container'); const recurringContainer = document.getElementById('recurring-event-list-container'); const pastSection = document.getElementById('past-events-section'); const recurringSection = document.getElementById('recurring-events-section'); const futureHeading = document.getElementById('future-events-heading'); const pastCount = document.getElementById('past-events-count'); const recurringCount = document.getElementById('recurring-events-count'); const eventTable = document.getElementById('event-table'); try { const showSnoozed = document.getElementById('filter-events-snoozed')?.checked || false; // list_events excludes snoozed by default; merge in list_snoozed_events // when the user opts in. De-dupe by id since recurring expansion may // collide with a snoozed template. const [mainEvents, snoozedEvents] = await Promise.all([ GoingsOn.api.events.list(), showSnoozed ? GoingsOn.api.events.listSnoozed() : Promise.resolve([]), ]); let events = mainEvents; if (snoozedEvents.length > 0) { const seen = new Set(events.map(e => e.id)); for (const ev of snoozedEvents) { if (!seen.has(ev.id)) { events.push(ev); seen.add(ev.id); } } } if (events.length === 0) { eventTable.style.display = 'none'; pastSection.classList.add('hidden'); recurringSection.classList.add('hidden'); if (futureHeading) futureHeading.classList.add('hidden'); upcomingContainer.innerHTML = GoingsOn.ui.renderEmptyState('No events scheduled.', 'Add Event', 'GoingsOn.events.openNew()', 'events'); [upcomingEventsScroller, pastEventsScroller, recurringEventsScroller].forEach(s => s && s.destroy()); upcomingEventsScroller = pastEventsScroller = recurringEventsScroller = null; return; } // Events come pre-sorted by start_time ASC from backend. events = events.map(e => ({ ...e, displayTitle: e.title })); // Recurring section shows TEMPLATES only (the parent rule), not each expanded // occurrence — so users can find the rule itself without scrolling past 50 // weekly instances. Templates are identified by recurrence !== 'None' and // !isRecurringInstance (the parent row, not a generated copy). const recurring = events.filter(e => e.recurrence && e.recurrence !== 'None' && !e.isRecurringInstance); const nonTemplate = events.filter(e => !(e.recurrence && e.recurrence !== 'None' && !e.isRecurringInstance)); GoingsOn.state.set('recurringEvents', recurring); GoingsOn.state.set('upcomingEvents', nonTemplate.filter(e => !e.isPast)); GoingsOn.state.set('pastEvents', nonTemplate.filter(e => e.isPast).reverse()); // --- Recurring (top) --- if (recurring.length > 0) { recurringSection.classList.remove('hidden'); recurringCount.textContent = recurring.length; if (!recurringEventsScroller) { recurringEventsScroller = new GoingsOn.VirtualScroller({ container: recurringContainer, renderItem: (e, i) => renderEventRow(e, i, false, true), getItems: () => GoingsOn.state.recurringEvents, rowHeight: { estimated: 52, measure: true }, overscan: 3, }); } else { recurringEventsScroller.refresh(); } } else { recurringSection.classList.add('hidden'); if (recurringEventsScroller) { recurringEventsScroller.destroy(); recurringEventsScroller = null; } } // --- Upcoming (middle) --- if (GoingsOn.state.upcomingEvents.length > 0) { eventTable.style.display = 'flex'; if (futureHeading) futureHeading.classList.remove('hidden'); if (!upcomingEventsScroller) { upcomingEventsScroller = new GoingsOn.VirtualScroller({ container: upcomingContainer, renderItem: renderEventRow, getItems: () => GoingsOn.state.upcomingEvents, rowHeight: { estimated: 52, measure: true }, overscan: 5, }); } else { upcomingEventsScroller.refresh(); } } else { eventTable.style.display = 'none'; if (futureHeading) futureHeading.classList.add('hidden'); upcomingContainer.innerHTML = '
No upcoming events
'; if (upcomingEventsScroller) { upcomingEventsScroller.destroy(); upcomingEventsScroller = null; } } // --- Past (bottom) --- if (GoingsOn.state.pastEvents.length > 0) { pastSection.classList.remove('hidden'); pastCount.textContent = GoingsOn.state.pastEvents.length; if (!pastEventsScroller) { pastEventsScroller = new GoingsOn.VirtualScroller({ container: pastContainer, renderItem: (e, i) => renderEventRow(e, i, true), getItems: () => GoingsOn.state.pastEvents, rowHeight: { estimated: 52, measure: true }, overscan: 3, }); } else { pastEventsScroller.refresh(); } } else { pastSection.classList.add('hidden'); if (pastEventsScroller) { pastEventsScroller.destroy(); pastEventsScroller = null; } } GoingsOn.cache.markLoaded('events'); } catch (err) { upcomingContainer.innerHTML = `
Failed to load events.
`; GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load events'), 'error', { action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('events'); load(); } }, duration: 8000, }); } } /** * Render a single event row as a div (for virtual scrolling). * @param {Object} e - Event object * @param {number} index - Event index * @param {boolean} isPast - Whether this is a past event * @returns {string} HTML string */ function renderEventRow(e, index, isPast = false, isRecurring = false) { const displayTitle = e.displayTitle || e.title; const startDate = new Date(e.startTime); const monthName = startDate.toLocaleDateString('en-US', { month: 'short' }); // Recurring template row: replace the date cell with the recurrence pattern label // so users see "Weekly · Mon Wed Fri" rather than a single arbitrary start date. if (isRecurring) { const patternLabel = e.recurrenceDisplay || e.recurrence || 'Recurring'; return `
${esc(patternLabel)}
${e.timeFormatted}
${esc(displayTitle)}
${e.location ? esc(e.location) : '-'}
`; } // Mobile: insert date group header when date changes from previous item let dateHeader = ''; if (GoingsOn.touch?.isTouchDevice && index > 0) { const items = isPast ? (GoingsOn.state.pastEvents || []) : (GoingsOn.state.upcomingEvents || []); const prevItem = items[index - 1]; if (prevItem) { const prevDate = new Date(prevItem.startTime).toDateString(); const curDate = startDate.toDateString(); if (prevDate !== curDate) { const dayName = startDate.toLocaleDateString('en-US', { weekday: 'long' }); dateHeader = `
${dayName}, ${startDate.getDate()} ${monthName}
`; } } } else if (GoingsOn.touch?.isTouchDevice && index === 0) { const dayName = startDate.toLocaleDateString('en-US', { weekday: 'long' }); dateHeader = `
${dayName}, ${startDate.getDate()} ${monthName}
`; } return ` ${dateHeader}
${startDate.getDate()} ${monthName} ${e.proximityLabel || ''}
${e.timeFormatted}
${esc(displayTitle)}
${e.location ? esc(e.location) : '-'}
`; } function openNew() { GoingsOn.ui.openFormModal({ title: 'New Event', entityType: 'event', isEdit: false, fields: getEventFormFields(), extraContent: buildRemindersHtml(null), onSubmit: create, }); GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence'); } /** * Open the new event form modal pre-filled for a specific project. * @param {string} projectId - Project to pre-select in the form */ function openNewForProject(projectId) { const project = GoingsOn.getProjectsCache().find(p => p.id === projectId); GoingsOn.ui.openFormModal({ title: 'New Event', entityType: 'event', isEdit: false, fields: getEventFormFields(null, projectId), presetData: { project_id: projectId }, onSubmit: create, extraContent: (project ? `
` : '') + buildRemindersHtml(null), }); GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence'); } /** * Create a new event from form data. * @param {Object} data - Form data with title, description, start_time, end_time, location, etc. */ /** * Phase 7 Tier 5 — snap start/end to midnight pair when "All day" is set. * The backend doesn't have a dedicated all-day flag, so we author the * canonical 00:00 → next-day-00:00 shape the calendar renderer detects. */ function _normalizeForAllDay(data) { if (!data.is_all_day) return { startTime: new Date(data.start_time).toISOString(), endTime: data.end_time ? new Date(data.end_time).toISOString() : null }; const start = new Date(data.start_time); if (isNaN(start.getTime())) { return { startTime: new Date(data.start_time).toISOString(), endTime: data.end_time ? new Date(data.end_time).toISOString() : null }; } const startDay = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0, 0); // End: if user gave an end date, snap to midnight after that day; else single-day event. let endDay; if (data.end_time) { const end = new Date(data.end_time); if (!isNaN(end.getTime())) { endDay = new Date(end.getFullYear(), end.getMonth(), end.getDate() + 1, 0, 0, 0, 0); } } if (!endDay) { endDay = new Date(startDay.getFullYear(), startDay.getMonth(), startDay.getDate() + 1, 0, 0, 0, 0); } return { startTime: startDay.toISOString(), endTime: endDay.toISOString() }; } async function create(data) { const form = document.querySelector('.modal-content form'); const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null; const { startTime, endTime } = _normalizeForAllDay(data); const input = { title: data.title, description: data.description || '', projectId: data.project_id || null, startTime, endTime, location: data.location || null, recurrence: data.recurrence || 'None', recurrenceRule, contactId: data.contact_id || null, blockType: data.block_type || null, reminderOffsetsSeconds: collectReminderOffsets(form), }; const reloadFns = [load]; const currentProjectId = GoingsOn.getCurrentProjectId(); if (currentProjectId) { reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId)); } GoingsOn.cache.invalidate('events'); await GoingsOn.ui.apiCall(GoingsOn.api.events.create(input), { successMessage: 'Event created!', errorMessage: 'Failed to create event', reload: reloadFns, }); } /** * Open the event detail modal with edit and delete actions. * @param {string} id - Event ID to open */ async function open(id) { try { const event = await GoingsOn.api.events.get(id); if (!event) return; const snoozeUntilLabel = event.isSnoozed && event.snoozedUntil ? GoingsOn.snooze.formatTime(event.snoozedUntil) : null; const snoozeButton = event.isSnoozed ? `` : ``; const snoozeStatus = snoozeUntilLabel ? `

Snoozed until: ${esc(snoozeUntilLabel)}

` : ''; const reminders = event.reminderOffsetsSeconds || []; const reminderLabels = reminders .map(s => REMINDER_PRESETS.find(p => p.seconds === s)?.label || `${s} seconds before`) .join(', '); const reminderStatus = reminders.length ? `

Reminders: ${esc(reminderLabels)}

` : ''; const content = `

${esc(event.title)}

${event.descriptionHtml || ''}

When: ${event.timeFormatted}

${event.location ? `

Where: ${esc(event.location)}

` : ''} ${snoozeStatus} ${reminderStatus}
${snoozeButton}
`; GoingsOn.ui.openModal('Event Details', content); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load event'), 'error'); } } /** * Delete an event with confirmation and undo support. * @param {string} id - Event ID to delete */ /** * For recurring events, surface the scope of the change before any edit/delete. * Per-occurrence overrides aren't supported by the backend yet — every edit * applies to the whole series. This dialog at least makes that explicit so * users don't silently cascade a change across all instances. * (Phase 4 #7 / Phase 7 Tier 1 #5.) * * @param {Object} event - Fetched event record * @param {string} action - 'edit' or 'delete' (verb for the prompt) * @returns {Promise} true if the user confirmed, false if cancelled */ async function confirmRecurringScope(event, action) { if (!event) return true; const isTemplate = !!(event.recurrence && event.recurrence !== 'None' && !event.isRecurringInstance); const isInstance = !!event.isRecurringInstance; if (!isTemplate && !isInstance) return true; const pattern = event.recurrence || 'recurring'; const verb = action === 'delete' ? 'Delete' : 'Edit'; const past = action === 'delete' ? 'deleted' : 'edited'; return GoingsOn.ui.showConfirmDialog( `${verb} recurring event`, `This event repeats (${pattern}). The whole series will be ${past} — per-occurrence overrides aren't supported yet.`, { confirmText: `${verb} entire series`, cancelText: 'Cancel', danger: action === 'delete' } ); } async function deleteEvent(id) { let eventRecord; try { eventRecord = await GoingsOn.api.events.get(id); } catch (_) { /* fetch failed; fall through */ } const isRecurring = !!(eventRecord && eventRecord.recurrence && eventRecord.recurrence !== 'None'); if (isRecurring) { // Recurring scope warning replaces the standard confirm dialog. if (!(await confirmRecurringScope(eventRecord, 'delete'))) return; } else { if (!await GoingsOn.ui.confirmDelete('event')) return; } GoingsOn.cache.invalidate('events'); const upcoming = GoingsOn.state.upcomingEvents || []; const past = GoingsOn.state.pastEvents || []; const removedUpcoming = upcoming.find(e => e.id === id); const removedPast = past.find(e => e.id === id); const removedEvent = removedUpcoming || removedPast; if (removedUpcoming) { GoingsOn.state.set('upcomingEvents', upcoming.filter(e => e.id !== id)); } if (removedPast) { GoingsOn.state.set('pastEvents', past.filter(e => e.id !== id)); } GoingsOn.ui.showUndoToast('Event deleted', { onConfirm: async () => { try { await GoingsOn.api.events.delete(id); } catch (err) { GoingsOn.ui.showToast('Failed to delete event', 'error'); load(); } }, onUndo: () => { if (removedEvent) { if (removedUpcoming) { GoingsOn.state.set('upcomingEvents', [...(GoingsOn.state.upcomingEvents || []), removedEvent]); } else { GoingsOn.state.set('pastEvents', [...(GoingsOn.state.pastEvents || []), removedEvent]); } } }, }); } /** * Fetch an event and open the edit form modal. * @param {string} id - Event ID to edit */ async function openEdit(id) { try { const event = await GoingsOn.api.events.get(id); if (!event) { GoingsOn.ui.showToast('Event not found', 'error'); return; } // For recurring events, surface the scope of the change before // opening the form. (Phase 4 #7 / Phase 7 Tier 1 #5.) if (!(await confirmRecurringScope(event, 'edit'))) return; GoingsOn.ui.openFormModal({ title: 'Edit Event', entityType: 'event', isEdit: true, entityId: id, fields: getEventFormFields(event), extraContent: buildRemindersHtml(event), onSubmit: (data) => update(id, data), }); GoingsOn.taskForms.initRecurrenceConfig('event', 'recurrence'); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load event'), 'error'); } } /** * Update an existing event from form data. * @param {string} id - Event ID to update * @param {Object} data - Form data with title, description, start_time, etc. */ async function update(id, data) { const form = document.querySelector('.modal-content form'); const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'event', data.recurrence) : null; const { startTime, endTime } = _normalizeForAllDay(data); const input = { title: data.title, description: data.description || '', startTime, endTime, location: data.location || null, recurrence: data.recurrence || 'None', recurrenceRule, contactId: data.contact_id || null, blockType: data.block_type || null, reminderOffsetsSeconds: collectReminderOffsets(form), }; GoingsOn.cache.invalidate('events'); await GoingsOn.ui.apiCall(GoingsOn.api.events.update(id, input), { successMessage: 'Event updated!', errorMessage: 'Failed to update event', reload: load, }); } // ============ Event Status Indicator ============ let statusPollInterval = null; /** * Fetches the aggregate event status indicator from the backend * and applies it to the UI status dots. All date math is done in Rust. */ async function updateEventStatusDot() { const leadMinutes = parseInt(localStorage.getItem('goingson-event-lead-minutes') || '15', 10); let status, label; try { const indicator = await GoingsOn.api.events.getStatusIndicator(leadMinutes); status = indicator.status; label = indicator.label; } catch { // Fallback if backend unavailable status = 'none'; label = 'Status unavailable'; } // Desktop: dot on the tab const tab = document.querySelector('.tab[data-view="events"]'); if (tab) { let dot = tab.querySelector('.tab-status-dot'); if (!dot) { dot = document.createElement('span'); dot.className = 'tab-status-dot'; tab.appendChild(dot); } dot.className = 'tab-status-dot status-' + status; dot.setAttribute('aria-label', label); } } function startEventStatusPolling() { updateEventStatusDot(); if (statusPollInterval) clearInterval(statusPollInterval); statusPollInterval = setInterval(updateEventStatusDot, 30000); } // ============ Populate GoingsOn.events Namespace ============ GoingsOn.events = { load, onFilterChange, openNew, openNewForProject, create, open, delete: deleteEvent, openEdit, update, // Bulk operations toggleEventSelection, selectAllEvents, clearEventSelection, bulkDelete: bulkDeleteEvents, // Event status indicator updateEventStatusDot, startEventStatusPolling, // Expose form fields for potential reuse getFormFields: getEventFormFields, // Virtual scrolling helpers renderEventRow, getUpcomingScroller: () => upcomingEventsScroller, getPastScroller: () => pastEventsScroller, }; })();