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