/**
* 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 `
`;
}
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 = ``;
}
}
} else if (GoingsOn.touch?.isTouchDevice && index === 0) {
const dayName = startDate.toLocaleDateString('en-US', { weekday: 'long' });
dateHeader = ``;
}
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}
`;
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,
};
})();