Skip to main content

max / goingson

GO-30-CSP W2+W3: migrate all inline on* handlers to delegated dispatch Converts all ~390 inline on* handlers across index.html/compose.html and ~40 JS render/interactive files to the data-act delegation convention (dispatch.js). Zero inline handlers remain in shipping code; all 251 distinct static action paths resolve to defined methods; all JS passes node --check. - Args carry sentinels (@event/@el/@value/@checked/@aN) so existing method signatures like showTask(event, id) are preserved; numeric/null/bool args use a static data-args JSON that references the string slots. - escArg (escapeHandlerArg) args swapped to escapeAttrValue when moved into data-* attributes (the JS-string escape layer would corrupt values in an attribute). - New delegated-event helpers in components.js: noop, closeModalThen, toggleMenu, menuAction, toggleExpand, setLocalStorage, onEnter, markSeenThen; utils.toggleQuoted; events.reload; export.backupThenManage. - Generic string-onClick component APIs (renderEmptyState, empty-state actions, contact-dashboard/contacts submit, email-accounts form) refactored to take an action dot-path + args instead of a handler string. - compose.html toolbar/form wired via addEventListener in its own script. The temporary dangerousDisableAssetCspModification scaffold (committed in W0+W1) still keeps the app usable; W8 removes it and drops 'unsafe-inline'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 21:04 UTC
Commit: fd23ac5f98f3f5a36ccbea5e96671be26fac8948
Parent: 84076c5
44 files changed, +1077 insertions, -954 deletions
@@ -326,7 +326,7 @@ async function openAboutModal() {
326 326 <p class="about-copyright">&copy; 2026 Make Creative, LLC</p>
327 327 </div>
328 328 <div class="form-actions">
329 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
329 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Close</button>
330 330 </div>
331 331 `;
332 332 GoingsOn.ui.openModal('About', content);
@@ -353,9 +353,9 @@ function showWelcome() {
353 353 <div class="welcome-section">
354 354 <h3 class="welcome-subhead">Get Started</h3>
355 355 <div class="welcome-step-stack">
356 - <button class="btn btn-secondary text-left" onclick="localStorage.setItem('go-welcomed', '1'); GoingsOn.ui.closeModal(); GoingsOn.keyboard.openQuickAdd();">${step1}</button>
357 - <button class="btn btn-secondary text-left" onclick="localStorage.setItem('go-welcomed', '1'); GoingsOn.ui.closeModal(); GoingsOn.navigation.switchView('day-plan');">${step2}</button>
358 - <button class="btn btn-secondary text-left" onclick="localStorage.setItem('go-welcomed', '1'); GoingsOn.ui.closeModal(); GoingsOn.emails.openAccountsModal();"><strong>3.</strong> Add an email account</button>
356 + <button class="btn btn-secondary text-left" data-act="ui.markSeenThen" data-a1="go-welcomed" data-a2="keyboard.openQuickAdd">${step1}</button>
357 + <button class="btn btn-secondary text-left" data-act="ui.markSeenThen" data-a1="go-welcomed" data-a2="navigation.switchView" data-a3="day-plan">${step2}</button>
358 + <button class="btn btn-secondary text-left" data-act="ui.markSeenThen" data-a1="go-welcomed" data-a2="emails.openAccountsModal"><strong>3.</strong> Add an email account</button>
359 359 </div>
360 360 </div>
361 361 <div class="welcome-section">
@@ -369,7 +369,7 @@ function showWelcome() {
369 369 ${shortcutsHint}
370 370 </div>
371 371 <div class="form-actions">
372 - <button class="btn btn-primary" onclick="localStorage.setItem('go-welcomed', '1'); GoingsOn.ui.closeModal()">Get Started</button>
372 + <button class="btn btn-primary" data-act="ui.markSeenThen" data-a1="go-welcomed">Get Started</button>
373 373 </div>
374 374 `;
375 375 GoingsOn.ui.openModal('Welcome to GoingsOn', content);
@@ -67,13 +67,13 @@
67 67 </div>
68 68 <div class="attachment-actions">
69 69 ${a.hasLocalBlob ? `
70 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.attachments.open('${escArg(a.id)}')" title="Open">Open</button>
71 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.attachments.saveAs('${escArg(a.id)}', '${escArg(a.filename)}')" title="Save As">Save</button>
70 + <button class="btn btn-sm btn-secondary" data-act="attachments.open" data-a1="${escAttr(a.id)}" title="Open">Open</button>
71 + <button class="btn btn-sm btn-secondary" data-act="attachments.saveAs" data-a1="${escAttr(a.id)}" data-a2="${escAttr(a.filename)}" title="Save As">Save</button>
72 72 ` : `
73 73 <span class="attachment-sync-warning">Sync needed</span>
74 74 `}
75 75 <button class="btn btn-sm btn-secondary text-accent-red"
76 - onclick="GoingsOn.attachments.remove('${escArg(a.id)}', '${tid}', '${pid}')" title="Delete">×</button>
76 + data-act="attachments.remove" data-a1="${escAttr(a.id)}" data-a2="${escAttr(tid)}" data-a3="${escAttr(pid)}" title="Delete">×</button>
77 77 </div>
78 78 </div>
79 79 `).join('');
@@ -83,7 +83,7 @@
83 83 <div>
84 84 ${listHtml}
85 85 <div class="attachment-attach-row">
86 - <button class="btn btn-primary" onclick="GoingsOn.attachments.pickAndAttach('${tid}', '${pid}')">Attach File</button>
86 + <button class="btn btn-primary" data-act="attachments.pickAndAttach" data-a1="${escAttr(tid)}" data-a2="${escAttr(pid)}">Attach File</button>
87 87 </div>
88 88 </div>
89 89 `;
@@ -56,7 +56,7 @@
56 56
57 57 for (const opt of options) {
58 58 optionsHtml += `
59 - <button class="snooze-option" onclick="GoingsOn.bulk._snoozeCallback('${escArg(opt.time)}')">
59 + <button class="snooze-option" data-act="bulk._snoozeCallback" data-a1="${escAttr(opt.time)}">
60 60 <span class="snooze-option-label">${esc(opt.label)}</span>
61 61 <span class="snooze-option-time">${esc(opt.formatted)}</span>
62 62 </button>
@@ -198,9 +198,9 @@
198 198
199 199 const projects = GoingsOn.projects?.getCache?.() || [];
200 200 let optionsHtml = `<p class="bulk-modal-prompt bulk-modal-prompt--wide">Set project for ${selectedTaskIds.size} tasks:</p>`;
201 - optionsHtml += `<button class="btn btn-sm text-left w-full bulk-modal-option-btn" onclick="GoingsOn.bulk._applyProject(null)">No Project</button>`;
201 + optionsHtml += `<button class="btn btn-sm text-left w-full bulk-modal-option-btn" data-act="bulk._applyProject" data-args='[null]'>No Project</button>`;
202 202 for (const p of projects) {
203 - optionsHtml += `<button class="btn btn-sm text-left w-full bulk-modal-option-btn" onclick="GoingsOn.bulk._applyProject('${escArg(p.id)}')">${GoingsOn.utils.escapeHtml(p.name)}</button>`;
203 + optionsHtml += `<button class="btn btn-sm text-left w-full bulk-modal-option-btn" data-act="bulk._applyProject" data-a1="${escAttr(p.id)}">${GoingsOn.utils.escapeHtml(p.name)}</button>`;
204 204 }
205 205 GoingsOn.ui.openModal('Set Project', `<div class="bulk-modal-scroll">${optionsHtml}</div>`);
206 206 }
@@ -212,9 +212,9 @@
212 212 const content = `
213 213 <p class="bulk-modal-prompt bulk-modal-prompt--wide">Set priority for ${selectedTaskIds.size} tasks:</p>
214 214 <div class="bulk-priority-row">
215 - <button class="btn btn-sm" onclick="GoingsOn.bulk._applyPriority('High')">High</button>
216 - <button class="btn btn-sm" onclick="GoingsOn.bulk._applyPriority('Medium')">Medium</button>
217 - <button class="btn btn-sm" onclick="GoingsOn.bulk._applyPriority('Low')">Low</button>
215 + <button class="btn btn-sm" data-act="bulk._applyPriority" data-a1="High">High</button>
216 + <button class="btn btn-sm" data-act="bulk._applyPriority" data-a1="Medium">Medium</button>
217 + <button class="btn btn-sm" data-act="bulk._applyPriority" data-a1="Low">Low</button>
218 218 </div>
219 219 `;
220 220 GoingsOn.ui.openModal('Set Priority', content);
@@ -5,6 +5,7 @@
5 5
6 6 (function() {
7 7 'use strict';
8 + const escAttr = GoingsOn.utils.escapeAttrValue;
8 9 const esc = GoingsOn.utils.escapeHtml;
9 10
10 11 // ============ Modal ============
@@ -180,7 +181,7 @@ function showUndoToast(message, { onConfirm, onUndo, timeout = 15000 }) {
180 181
181 182 toast.innerHTML = `
182 183 <span class="undo-message">${esc(message)}</span>
183 - <button class="btn btn-sm btn-primary" onclick="GoingsOn.ui.executeUndo('${undoId}')">Undo</button>
184 + <button class="btn btn-sm btn-primary" data-act="ui.executeUndo" data-a1="${escAttr(undoId)}">Undo</button>
184 185 <span class="undo-countdown"></span>
185 186 `;
186 187
@@ -11,6 +11,75 @@
11 11
12 12 const openModal = (...args) => GoingsOn.modal.openModal(...args);
13 13 const closeModal = (...args) => GoingsOn.modal.closeModal(...args);
14 +
15 + /** Resolve a `GoingsOn` dot-path (e.g. "emails.open") to its function. */
16 + function resolvePath(path) {
17 + return String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), window.GoingsOn);
18 + }
19 +
20 + /** A do-nothing action. Put `data-act="ui.noop"` on a wrapper so delegation's
21 + * `closest()` stops there instead of bubbling to an ancestor's action
22 + * (replaces the old inline `event.stopPropagation()` guards). */
23 + function noop() {}
24 +
25 + /**
26 + * Close the active modal, then resolve a `GoingsOn` dot-path and call it with
27 + * the given args. Replaces inline `onclick="ui.closeModal(); foo.bar('x')"`
28 + * two-statement handlers with a single delegated action.
29 + */
30 + function closeModalThen(path, ...args) {
31 + closeModal();
32 + const fn = resolvePath(path);
33 + if (typeof fn === 'function') fn(...args);
34 + }
35 +
36 + /** Toggle the `show` class on the element's next sibling (dropdown menus). */
37 + function toggleMenu(el) {
38 + if (el && el.nextElementSibling) el.nextElementSibling.classList.toggle('show');
39 + }
40 +
41 + /** Run a `GoingsOn` action, then close the dropdown the trigger sits in
42 + * (replaces `foo(); this.parentElement.classList.remove('show')`). */
43 + function menuAction(el, path, ...args) {
44 + const fn = resolvePath(path);
45 + if (typeof fn === 'function') fn(...args);
46 + if (el && el.parentElement) el.parentElement.classList.remove('show');
47 + }
48 +
49 + /** Toggle an "expanded" section: flips `expanded` on the button and `hidden`
50 + * on its next sibling; optionally swaps label text from data attributes. */
51 + function toggleExpand(el) {
52 + if (!el) return;
53 + el.classList.toggle('expanded');
54 + if (el.nextElementSibling) el.nextElementSibling.classList.toggle('hidden');
55 + const on = el.dataset.textExpanded, off = el.dataset.textCollapsed;
56 + if (on && off) el.textContent = el.classList.contains('expanded') ? on : off;
57 + }
58 +
59 + /** Set a localStorage key (replaces inline `localStorage.setItem(...)`). */
60 + function setLocalStorage(key, value) {
61 + try { localStorage.setItem(key, value); } catch (e) { /* storage disabled */ }
62 + }
63 +
64 + /** Run a `GoingsOn` action only when Enter was pressed (for keydown handlers). */
65 + function onEnter(event, path, ...args) {
66 + if (event && event.key === 'Enter') {
67 + const fn = resolvePath(path);
68 + if (typeof fn === 'function') fn(...args);
69 + }
70 + }
71 +
72 + /** Set a localStorage flag to "1", close the modal, then optionally run an
73 + * action. Replaces the welcome-modal `localStorage.setItem(...); closeModal();
74 + * foo()` three-statement handlers. */
75 + function markSeenThen(key, path, ...args) {
76 + setLocalStorage(key, '1');
77 + closeModal();
78 + if (path) {
79 + const fn = resolvePath(path);
80 + if (typeof fn === 'function') fn(...args);
81 + }
82 + }
14 83 const showToast = (...args) => GoingsOn.modal.showToast(...args);
15 84 const showUndoToast = (...args) => GoingsOn.modal.showUndoToast(...args);
16 85 const bulkActionWithUndo = (...args) => GoingsOn.modal.bulkActionWithUndo(...args);
@@ -59,14 +128,14 @@ function emptyStateIcon(key) {
59 128 * so empty/onboarding states read as one designed pattern.
60 129 * @param {string} message - The empty state message text
61 130 * @param {string} [buttonLabel] - Optional button label
62 - * @param {string} [onClickFn] - Optional onclick handler (as a string, e.g., "GoingsOn.tasks.openNew()")
131 + * @param {string} [onClickAct] - Optional delegated action dot-path (e.g. "tasks.openNew")
63 132 * @param {string} [iconKey] - Optional EMPTY_STATE_ICONS key for a leading icon
64 133 * @returns {string} - HTML string for the empty state
65 134 */
66 - function renderEmptyState(message, buttonLabel, onClickFn, iconKey) {
135 + function renderEmptyState(message, buttonLabel, onClickAct, iconKey) {
67 136 let html = `<div class="empty-state">${emptyStateIcon(iconKey)}<p class="empty-state-text">${GoingsOn.utils.escapeHtml(message)}</p>`;
68 - if (buttonLabel && onClickFn) {
69 - html += `<button class="btn btn-primary empty-state-action" onclick="${GoingsOn.utils.escapeAttrValue(onClickFn)}">${GoingsOn.utils.escapeHtml(buttonLabel)}</button>`;
137 + if (buttonLabel && onClickAct) {
138 + html += `<button class="btn btn-primary empty-state-action" data-act="${GoingsOn.utils.escapeAttrValue(onClickAct)}">${GoingsOn.utils.escapeHtml(buttonLabel)}</button>`;
70 139 }
71 140 html += `</div>`;
72 141 return html;
@@ -507,6 +576,16 @@ GoingsOn.ui = {
507 576 // Modal (delegated to components-modal.js)
508 577 openModal,
509 578 closeModal,
579 + closeModalThen,
580 +
581 + // Delegated-event helpers (replace inline on* handlers; see dispatch.js)
582 + noop,
583 + toggleMenu,
584 + menuAction,
585 + toggleExpand,
586 + setLocalStorage,
587 + onEnter,
588 + markSeenThen,
510 589
511 590 // Toast notifications
512 591 showToast,
@@ -59,13 +59,13 @@
59 59 ? `<span class="tag status-${(item.status || '').toLowerCase()}">${esc(item.status)}</span>`
60 60 : '';
61 61
62 - let onclick = '';
63 - if (item.kind === 'task') onclick = `GoingsOn.taskOverview.open('${escArg(item.id)}')`;
64 - else if (item.kind === 'event') onclick = `GoingsOn.events.open('${escArg(item.id)}')`;
65 - else if (item.kind === 'email') onclick = `GoingsOn.emails.open('${escArg(item.id)}')`;
62 + let act = '';
63 + if (item.kind === 'task') act = 'taskOverview.open';
64 + else if (item.kind === 'event') act = 'events.open';
65 + else if (item.kind === 'email') act = 'emails.open';
66 66
67 67 return `
68 - <div class="contact-timeline-item row-flex row-flex-3" onclick="${onclick}" role="button" tabindex="0">
68 + <div class="contact-timeline-item row-flex row-flex-3" ${act ? `data-act="${act}" data-a1="${escAttr(item.id)}"` : ''} role="button" tabindex="0">
69 69 <span class="contact-timeline-icon">${icon}</span>
70 70 <span class="contact-timeline-title">${esc(item.title)}</span>
71 71 ${badge}
@@ -81,12 +81,12 @@
81 81 // Actions bar
82 82 if (contact.isImplicit) {
83 83 actionsEl.innerHTML = `
84 - <button class="btn btn-primary" onclick="GoingsOn.contactDashboard.promote('${escArg(contact.id)}')">Save as Contact</button>
84 + <button class="btn btn-primary" data-act="contactDashboard.promote" data-a1="${escAttr(contact.id)}">Save as Contact</button>
85 85 `;
86 86 } else {
87 87 actionsEl.innerHTML = `
88 - <button class="btn btn-secondary" onclick="GoingsOn.contacts.openEdit('${escArg(contact.id)}')">Edit</button>
89 - <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.contacts.delete('${escArg(contact.id)}')">Delete</button>
88 + <button class="btn btn-secondary" data-act="contacts.openEdit" data-a1="${escAttr(contact.id)}">Edit</button>
89 + <button class="btn btn-secondary text-accent-red" data-act="contacts.delete" data-a1="${escAttr(contact.id)}">Delete</button>
90 90 `;
91 91 }
92 92
@@ -137,7 +137,7 @@
137 137 const items = timelineItems.map(renderTimelineItem).join('');
138 138 // Rust caps the initial feed; offer to load the rest without a second round of client math.
139 139 const showAll = totalActivity > timelineItems.length
140 - ? `<button class="btn btn-link" onclick="GoingsOn.contactDashboard.showAllActivity()">Show all ${totalActivity} interactions</button>`
140 + ? `<button class="btn btn-link" data-act="contactDashboard.showAllActivity">Show all ${totalActivity} interactions</button>`
141 141 : '';
142 142 timelineHtml = `<div class="contact-timeline" id="contact-timeline">${items}</div>${showAll}`;
143 143 }
@@ -40,11 +40,11 @@
40 40 ).join('');
41 41
42 42 return `
43 - <div class="card contact-card" onclick="GoingsOn.contacts.open('${escArg(c.id)}')"
43 + <div class="card contact-card" data-act="contacts.open" data-a1="${escAttr(c.id)}"
44 44 tabindex="0" role="button" aria-label="Open contact ${esc(c.displayName)}">
45 45 <div class="card-header contact-card-header">
46 46 <input type="checkbox" class="bulk-checkbox contact-select-cb" data-id="${escAttr(c.id)}"
47 - onclick="event.stopPropagation(); GoingsOn.contacts.toggleSelection('${escArg(c.id)}', event)"
47 + data-act="contacts.toggleSelection" data-a1="${escAttr(c.id)}" data-a2="@event"
48 48 aria-label="Select ${esc(c.displayName)}">
49 49 <div class="avatar">${esc(initials)}</div>
50 50 <div class="contact-card-body">
@@ -52,7 +52,7 @@
52 52 ${nickname}
53 53 ${company}
54 54 </div>
55 - <button class="btn btn-sm btn-secondary" onclick="event.stopPropagation(); GoingsOn.contacts.openEdit('${escArg(c.id)}')" title="Edit">...</button>
55 + <button class="btn btn-sm btn-secondary" data-act="contacts.openEdit" data-a1="${escAttr(c.id)}" title="Edit">...</button>
56 56 </div>
57 57 ${emailLine ? `<p class="card-description">${emailLine}</p>` : ''}
58 58 ${tagPills ? `<div class="card-meta">${tagPills}</div>` : ''}
@@ -86,7 +86,7 @@
86 86 const emailRows = (contact.emails || []).map(e => `
87 87 <div class="sub-item">
88 88 <span>${esc(e.address)} ${e.label ? `<small>(${esc(e.label)})</small>` : ''} ${e.isPrimary ? '<strong>Primary</strong>' : ''}</span>
89 - <button class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.removeEmail('${escArg(contact.id)}', '${escArg(e.id)}')" title="Remove">x</button>
89 + <button class="btn btn-sm btn-danger" data-act="contacts.removeEmail" data-a1="${escAttr(contact.id)}" data-a2="${escAttr(e.id)}" title="Remove">x</button>
90 90 </div>
91 91 `).join('');
92 92
@@ -94,7 +94,7 @@
94 94 const phoneRows = (contact.phones || []).map(p => `
95 95 <div class="sub-item">
96 96 <span>${esc(p.number)} ${p.label ? `<small>(${esc(p.label)})</small>` : ''} ${p.isPrimary ? '<strong>Primary</strong>' : ''}</span>
97 - <button class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.removePhone('${escArg(contact.id)}', '${escArg(p.id)}')" title="Remove">x</button>
97 + <button class="btn btn-sm btn-danger" data-act="contacts.removePhone" data-a1="${escAttr(contact.id)}" data-a2="${escAttr(p.id)}" title="Remove">x</button>
98 98 </div>
99 99 `).join('');
100 100
@@ -102,7 +102,7 @@
102 102 const socialRows = (contact.socialHandles || []).map(s => `
103 103 <div class="sub-item">
104 104 <span><strong>${esc(s.platform)}:</strong> ${s.url ? `<a href="${escAttrVal(safeUrl(s.url))}" target="_blank">${esc(s.handle)}</a>` : esc(s.handle)}</span>
105 - <button class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.removeSocialHandle('${escArg(contact.id)}', '${escArg(s.id)}')" title="Remove">x</button>
105 + <button class="btn btn-sm btn-danger" data-act="contacts.removeSocialHandle" data-a1="${escAttr(contact.id)}" data-a2="${escAttr(s.id)}" title="Remove">x</button>
106 106 </div>
107 107 `).join('');
108 108
@@ -110,7 +110,7 @@
110 110 const customFieldRows = (contact.customFields || []).map(f => `
111 111 <div class="sub-item">
112 112 <span><strong>${esc(f.label)}:</strong> ${f.url ? `<a href="${escAttrVal(safeUrl(f.url))}" target="_blank">${esc(f.value)}</a>` : esc(f.value)}</span>
113 - <button class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.removeCustomField('${escArg(contact.id)}', '${escArg(f.id)}')" title="Remove">x</button>
113 + <button class="btn btn-sm btn-danger" data-act="contacts.removeCustomField" data-a1="${escAttr(contact.id)}" data-a2="${escAttr(f.id)}" title="Remove">x</button>
114 114 </div>
115 115 `).join('');
116 116
@@ -131,7 +131,7 @@
131 131 <div class="sub-collection">
132 132 <div class="sub-collection-header">
133 133 <h4>Email Addresses</h4>
134 - <button class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddEmail('${escArg(contact.id)}')">+ Add</button>
134 + <button class="btn btn-sm btn-primary" data-act="contacts.openAddEmail" data-a1="${escAttr(contact.id)}">+ Add</button>
135 135 </div>
136 136 ${emailRows || '<div class="sub-empty">No email addresses</div>'}
137 137 </div>
@@ -139,7 +139,7 @@
139 139 <div class="sub-collection">
140 140 <div class="sub-collection-header">
141 141 <h4>Phone Numbers</h4>
142 - <button class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddPhone('${escArg(contact.id)}')">+ Add</button>
142 + <button class="btn btn-sm btn-primary" data-act="contacts.openAddPhone" data-a1="${escAttr(contact.id)}">+ Add</button>
143 143 </div>
144 144 ${phoneRows || '<div class="sub-empty">No phone numbers</div>'}
145 145 </div>
@@ -147,7 +147,7 @@
147 147 <div class="sub-collection">
148 148 <div class="sub-collection-header">
149 149 <h4>Social Handles</h4>
150 - <button class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddSocial('${escArg(contact.id)}')">+ Add</button>
150 + <button class="btn btn-sm btn-primary" data-act="contacts.openAddSocial" data-a1="${escAttr(contact.id)}">+ Add</button>
151 151 </div>
152 152 ${socialRows || '<div class="sub-empty">No social handles</div>'}
153 153 </div>
@@ -155,13 +155,13 @@
155 155 <div class="sub-collection">
156 156 <div class="sub-collection-header">
157 157 <h4>Custom Fields</h4>
158 - <button class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddCustomField('${escArg(contact.id)}')">+ Add</button>
158 + <button class="btn btn-sm btn-primary" data-act="contacts.openAddCustomField" data-a1="${escAttr(contact.id)}">+ Add</button>
159 159 </div>
160 160 ${customFieldRows || '<div class="sub-empty">No custom fields</div>'}
161 161 </div>
162 162
163 163 <div class="form-actions form-actions--spaced">
164 - <button class="btn btn-secondary" onclick="GoingsOn.contacts.openEdit('${escArg(contact.id)}')">Edit Contact</button>
164 + <button class="btn btn-secondary" data-act="contacts.openEdit" data-a1="${escAttr(contact.id)}">Edit Contact</button>
165 165 </div>
166 166 </div>
167 167 `;
@@ -292,16 +292,13 @@
292 292 const isEdit = !!editingId;
293 293 const submitFn = isEdit ? EDIT_SUBMIT_FN[type] : ADD_SUBMIT_FN[type];
294 294 const submitText = isEdit ? (config.editButtonText || 'Save') : config.submitButtonText;
295 - const onclick = isEdit
296 - ? `GoingsOn.contacts.${submitFn}('${escArg(contactId)}', '${escArg(editingId)}')`
297 - : `GoingsOn.contacts.${submitFn}('${escArg(contactId)}')`;
298 295
299 296 return `
300 297 <form id="${escAttr(config.formId)}">
301 298 ${fieldHtml}
302 299 <div class="form-actions">
303 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
304 - <button type="button" class="btn btn-primary" onclick="${onclick}">${esc(submitText)}</button>
300 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
301 + <button type="button" class="btn btn-primary" data-act="contacts.${submitFn}" data-a1="${escAttr(contactId)}"${isEdit ? ` data-a2="${escAttr(editingId)}"` : ''}>${esc(submitText)}</button>
305 302 </div>
306 303 </form>
307 304 `;
@@ -578,7 +575,7 @@
578 575
579 576 if (filtered.length === 0) {
580 577 if (contacts.length === 0) {
581 - grid.innerHTML = GoingsOn.ui.renderEmptyState('No contacts yet.', 'Add Contact', 'GoingsOn.contacts.openNew()', 'contacts');
578 + grid.innerHTML = GoingsOn.ui.renderEmptyState('No contacts yet.', 'Add Contact', 'contacts.openNew', 'contacts');
582 579 } else {
583 580 grid.innerHTML = GoingsOn.ui.renderEmptyState('No contacts match your filters.');
584 581 }
@@ -629,8 +626,8 @@
629 626 // `GoingsOn.state.contacts` rather than smuggled through HTML attrs.
630 627 const rowActions = (kind, rowId) => `
631 628 <span class="sub-item-actions">
632 - <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.contacts.openEdit${kind}('${escArg(id)}', '${escArg(rowId)}')" title="Edit">Edit</button>
633 - <button type="button" class="btn btn-sm btn-danger" onclick="GoingsOn.contacts.remove${kind === 'Social' ? 'SocialHandle' : kind}('${escArg(id)}', '${escArg(rowId)}')" title="Remove">x</button>
629 + <button type="button" class="btn btn-sm btn-secondary" data-act="contacts.openEdit${kind}" data-a1="${escAttr(id)}" data-a2="${escAttr(rowId)}" title="Edit">Edit</button>
630 + <button type="button" class="btn btn-sm btn-danger" data-act="contacts.remove${kind === 'Social' ? 'SocialHandle' : kind}" data-a1="${escAttr(id)}" data-a2="${escAttr(rowId)}" title="Remove">x</button>
634 631 </span>
635 632 `;
636 633
@@ -662,37 +659,37 @@
662 659 <div class="edit-sub-section">
663 660 <div class="edit-sub-header">
664 661 <strong>Emails</strong>
665 - <button type="button" class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddEmail('${escArg(id)}')">+ Add</button>
662 + <button type="button" class="btn btn-sm btn-primary" data-act="contacts.openAddEmail" data-a1="${escAttr(id)}">+ Add</button>
666 663 </div>
667 664 ${emailSummary}
668 665 </div>
669 666 <div class="edit-sub-section">
670 667 <div class="edit-sub-header">
671 668 <strong>Phones</strong>
672 - <button type="button" class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddPhone('${escArg(id)}')">+ Add</button>
669 + <button type="button" class="btn btn-sm btn-primary" data-act="contacts.openAddPhone" data-a1="${escAttr(id)}">+ Add</button>
673 670 </div>
674 671 ${phoneSummary}
675 672 </div>
676 673 <div class="edit-sub-section">
677 674 <div class="edit-sub-header">
678 675 <strong>Social</strong>
679 - <button type="button" class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddSocial('${escArg(id)}')">+ Add</button>
676 + <button type="button" class="btn btn-sm btn-primary" data-act="contacts.openAddSocial" data-a1="${escAttr(id)}">+ Add</button>
680 677 </div>
681 678 ${socialSummary}
682 679 </div>
683 680 <div class="edit-sub-section">
684 681 <div class="edit-sub-header">
685 682 <strong>Custom Fields</strong>
686 - <button type="button" class="btn btn-sm btn-primary" onclick="GoingsOn.contacts.openAddCustomField('${escArg(id)}')">+ Add</button>
683 + <button type="button" class="btn btn-sm btn-primary" data-act="contacts.openAddCustomField" data-a1="${escAttr(id)}">+ Add</button>
687 684 </div>
688 685 ${customFieldSummary}
689 686 </div>
690 687 <div style="margin-top: 0.5rem;">
691 - <button type="button" class="btn btn-sm btn-secondary" onclick="GoingsOn.contacts.open('${escArg(id)}')">View Full Detail</button>
688 + <button type="button" class="btn btn-sm btn-secondary" data-act="contacts.open" data-a1="${escAttr(id)}">View Full Detail</button>
692 689 </div>
693 690 </div>
694 691 <div style="margin-top: 1rem;">
695 - <button type="button" class="btn btn-danger" onclick="GoingsOn.contacts.deleteContact('${escArg(id)}')">Delete Contact</button>
692 + <button type="button" class="btn btn-danger" data-act="contacts.deleteContact" data-a1="${escAttr(id)}">Delete Contact</button>
696 693 </div>
697 694 `,
698 695 });
@@ -119,7 +119,7 @@
119 119 <form id="painted-event-form">
120 120 <div class="form-group">
121 121 <label class="form-label">What to create</label>
122 - <select class="form-select" name="item_mode" onchange="GoingsOn.dayPlan.togglePaintMode(this)">
122 + <select class="form-select" name="item_mode" data-change="dayPlan.togglePaintMode" data-a1="@el">
123 123 <option value="event">Event</option>
124 124 <option value="block">Time Block</option>
125 125 <option value="task">Link to Task</option>
@@ -164,12 +164,12 @@
164 164 <div class="form-group">
165 165 <label class="form-label">Start</label>
166 166 <input type="datetime-local" class="form-input" name="start_time" value="${startISO}"
167 - onchange="GoingsOn.dayPlan.updatePaintTimePreview()">
167 + data-change="dayPlan.updatePaintTimePreview">
168 168 </div>
169 169 <div class="form-group">
170 170 <label class="form-label">End</label>
171 171 <input type="datetime-local" class="form-input" name="end_time" value="${endISO}"
172 - onchange="GoingsOn.dayPlan.updatePaintTimePreview()">
172 + data-change="dayPlan.updatePaintTimePreview">
173 173 </div>
174 174 </div>
175 175
@@ -179,8 +179,8 @@
179 179 </div>
180 180
181 181 <div class="form-actions">
182 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
183 - <button type="button" class="btn btn-primary" onclick="GoingsOn.dayPlan.submitPaintedEvent()">Create</button>
182 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
183 + <button type="button" class="btn btn-primary" data-act="dayPlan.submitPaintedEvent">Create</button>
184 184 </div>
185 185 </form>
186 186 `;
@@ -66,15 +66,15 @@
66 66 // Desktop: drag-paint via mouse events. Touch: tap via onclick, long-press wired post-render.
67 67 const paintHandlers = isTouch
68 68 ? ''
69 - : ` onmousedown="GoingsOn.dayPlan.onPaintStart(event, ${slotIdx}, '${escArg(slotTimestamp)}')"
70 - onmouseenter="GoingsOn.dayPlan.onPaintMove(event, ${slotIdx}, '${escArg(slotTimestamp)}')"`;
69 + : ` data-mousedown="dayPlan.onPaintStart" data-a1="${escAttr(slotTimestamp)}" data-args='["@event", ${slotIdx}, "@a1"]'
70 + data-mouseenter="dayPlan.onPaintMove" data-a1="${escAttr(slotTimestamp)}" data-args='["@event", ${slotIdx}, "@a1"]'`;
71 71
72 72 slotsHtml += `
73 73 <div class="timeline-slot${isHourStart ? ' hour-start' : ''}"
74 74 data-time="${escAttr(slotTimestamp)}"
75 75 data-hour="${hour}"
76 76 data-slot-index="${slotIdx}"${paintHandlers}
77 - onclick="GoingsOn.dayPlan.onSlotTap(event, ${slotIdx}, '${escArg(slotTimestamp)}')">
77 + data-act="dayPlan.onSlotTap" data-a1="${escAttr(slotTimestamp)}" data-args='["@event", ${slotIdx}, "@a1"]'>
78 78 <div class="timeline-time">${isHourStart ? timeStr : ''}</div>
79 79 <div class="timeline-slot-area"></div>
80 80 </div>
@@ -117,7 +117,7 @@
117 117 // Touch: tap opens, long-press opens action sheet (wired post-render). No mouse drag.
118 118 const dragHandler = isTouch
119 119 ? ''
120 - : ` onmousedown="GoingsOn.dayPlan.onItemDragStart(event, '${escArg(item.id)}', '${escArg(item.itemType)}')"`;
120 + : ` data-mousedown="dayPlan.onItemDragStart" data-a1="@event" data-a2="${escAttr(item.id)}" data-a3="${escAttr(item.itemType)}"`;
121 121 const titleHint = isTouch ? '' : ' (drag to reschedule)';
122 122 itemsHtml += `
123 123 <div class="timeline-item ${item.itemType} ${blockClass} ${hasConflict ? 'conflict' : ''}"
@@ -125,8 +125,8 @@
125 125 data-id="${escAttr(item.id)}"
126 126 data-type="${escAttr(item.itemType)}"
127 127 data-duration="${duration}"
128 - onclick="GoingsOn.dayPlan.openTimelineItem('${escArg(item.id)}', '${escArg(item.itemType)}')"${dragHandler}
129 - onkeydown="GoingsOn.dayPlan.handleTimelineItemKeydown(event, '${escArg(item.id)}', '${escArg(item.itemType)}')"
128 + data-act="dayPlan.openTimelineItem" data-a1="${escAttr(item.id)}" data-a2="${escAttr(item.itemType)}"${dragHandler}
129 + data-keydown="dayPlan.handleTimelineItemKeydown" data-a1="@event" data-a2="${escAttr(item.id)}" data-a3="${escAttr(item.itemType)}"
130 130 title="${escAttr(item.title)}${titleHint}${keyboardHint}"
131 131 tabindex="0" role="button" aria-label="${escAttr(item.title)}${keyboardHint}">
132 132 <div class="timeline-item-title">${esc(item.title)}</div>
@@ -146,16 +146,16 @@
146 146 return `
147 147 <div class="unscheduled-task priority-${task.priority.toLowerCase()}"
148 148 data-id="${escAttr(task.id)}"
149 - onclick="GoingsOn.tasks.openSubtasks('${escArg(task.id)}')"
150 - onkeydown="GoingsOn.dayPlan.handleUnscheduledTaskKeydown(event, '${escArg(task.id)}')"
149 + data-act="tasks.openSubtasks" data-a1="${escAttr(task.id)}"
150 + data-keydown="dayPlan.handleUnscheduledTaskKeydown" data-a1="@event" data-a2="${escAttr(task.id)}"
151 151 tabindex="0" role="listitem" aria-label="Unscheduled task: ${esc(task.description)} (Press S to schedule)">
152 152 <div class="unscheduled-task-title">${esc(task.description)}</div>
153 153 <div class="unscheduled-task-meta">
154 154 ${task.projectName ? esc(task.projectName) + ' - ' : ''}${task.priority}
155 155 </div>
156 - <div class="unscheduled-task-actions" onclick="event.stopPropagation()">
157 - <button class="btn btn-sm btn-ghost" onclick="GoingsOn.timeTracking.startTimer('${escArg(task.id)}')" title="Track Time">Track</button>
158 - <button class="btn btn-sm btn-ghost" onclick="GoingsOn.focusTimer.start('${escArg(task.id)}')" title="Focus Mode">Focus</button>
156 + <div class="unscheduled-task-actions" data-act="ui.noop">
157 + <button class="btn btn-sm btn-ghost" data-act="timeTracking.startTimer" data-a1="${escAttr(task.id)}" title="Track Time">Track</button>
158 + <button class="btn btn-sm btn-ghost" data-act="focusTimer.start" data-a1="${escAttr(task.id)}" title="Focus Mode">Focus</button>
159 159 </div>
160 160 </div>
161 161 `;
@@ -33,7 +33,7 @@
33 33 }
34 34
35 35 const timeSlotsHtml = timeSlots.slice(0, 12).map(slot => `
36 - <button class="btn btn-sm btn-secondary time-block-quick-btn" onclick="GoingsOn.dayPlan.selectTimeSlot(this, '${slot.time.toISOString()}')">
36 + <button class="btn btn-sm btn-secondary time-block-quick-btn" data-act="dayPlan.selectTimeSlot" data-a1="@el" data-a2="${escAttr(slot.time.toISOString())}">
37 37 ${slot.label}
38 38 </button>
39 39 `).join('');
@@ -57,12 +57,12 @@
57 57 <div class="form-group">
58 58 <label class="form-label" id="schedule-duration-label">Duration</label>
59 59 <div class="duration-presets" role="group" aria-labelledby="schedule-duration-label">
60 - <button class="duration-preset" onclick="GoingsOn.dayPlan.selectDuration(this, 15)">15m</button>
61 - <button class="duration-preset selected" onclick="GoingsOn.dayPlan.selectDuration(this, 30)">30m</button>
62 - <button class="duration-preset" onclick="GoingsOn.dayPlan.selectDuration(this, 45)">45m</button>
63 - <button class="duration-preset" onclick="GoingsOn.dayPlan.selectDuration(this, 60)">1h</button>
64 - <button class="duration-preset" onclick="GoingsOn.dayPlan.selectDuration(this, 90)">1.5h</button>
65 - <button class="duration-preset" onclick="GoingsOn.dayPlan.selectDuration(this, 120)">2h</button>
60 + <button class="duration-preset" data-act="dayPlan.selectDuration" data-args='["@el", 15]'>15m</button>
61 + <button class="duration-preset selected" data-act="dayPlan.selectDuration" data-args='["@el", 30]'>30m</button>
62 + <button class="duration-preset" data-act="dayPlan.selectDuration" data-args='["@el", 45]'>45m</button>
63 + <button class="duration-preset" data-act="dayPlan.selectDuration" data-args='["@el", 60]'>1h</button>
64 + <button class="duration-preset" data-act="dayPlan.selectDuration" data-args='["@el", 90]'>1.5h</button>
65 + <button class="duration-preset" data-act="dayPlan.selectDuration" data-args='["@el", 120]'>2h</button>
66 66 </div>
67 67 <input type="hidden" id="schedule-duration" value="30">
68 68 </div>
@@ -70,8 +70,8 @@
70 70 <div id="schedule-conflict-warning"></div>
71 71
72 72 <div class="form-actions">
73 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
74 - <button type="button" class="btn btn-primary" onclick="GoingsOn.dayPlan.scheduleTaskFromModal('${escArg(id)}')">Schedule Task</button>
73 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
74 + <button type="button" class="btn btn-primary" data-act="dayPlan.scheduleTaskFromModal" data-a1="${escAttr(id)}">Schedule Task</button>
75 75 </div>
76 76 </div>
77 77 `;
@@ -244,14 +244,14 @@
244 244 bar.classList.remove('hidden');
245 245 if (state === 'current') {
246 246 bar.innerHTML = `
247 - <button class="btn btn-primary finish-review-btn" id="day-finish-review-btn" onclick="GoingsOn.dayPlanSchedule.openFinishReviewModal()">
247 + <button class="btn btn-primary finish-review-btn" id="day-finish-review-btn" data-act="dayPlanSchedule.openFinishReviewModal">
248 248 Finish &amp; Review
249 249 </button>
250 250 `;
251 251 } else {
252 252 const label = isReviewed ? 'View Past Review' : 'Review Past Day';
253 253 bar.innerHTML = `
254 - <button class="btn btn-secondary finish-review-btn" id="day-finish-review-btn" onclick="GoingsOn.dayPlanSchedule.openFinishReviewModal()">
254 + <button class="btn btn-secondary finish-review-btn" id="day-finish-review-btn" data-act="dayPlanSchedule.openFinishReviewModal">
255 255 ${label}
256 256 </button>
257 257 `;
@@ -306,7 +306,7 @@
306 306 ${banner}
307 307 ${reflectionHtml}
308 308 <div class="review-actions-grid">
309 - <button type="button" class="btn btn-primary" onclick="GoingsOn.dayPlan.saveDailyReview()">
309 + <button type="button" class="btn btn-primary" data-act="dayPlan.saveDailyReview">
310 310 ${isReviewed ? 'Update Review' : 'Save Review'}
311 311 </button>
312 312 </div>
@@ -16,14 +16,22 @@
16 16 * `data-keydown`, `data-contextmenu`, `data-mousedown` hold a dot-path
17 17 * resolved against `window.GoingsOn`.
18 18 * - Positional args come from `data-a1`, `data-a2`, … (strings), or a single
19 - * `data-args` holding a JSON array (for typed / null / multi args).
20 - * - Handlers are called `fn(...args, ctx)` with `ctx = { event, el }`. Methods
21 - * that need the event or element read the trailing `ctx`.
22 - * - `change` / `input` pass the element's value first: `fn(value, ...args, ctx)`,
23 - * matching the old `onchange="foo(this.value)"` idiom.
24 - * - Nested clickables: the innermost `[data-act]` wins via `closest()`, so no
25 - * `event.stopPropagation()` is needed — a row and a button inside it can both
26 - * carry `data-act` and only the button fires.
19 + * `data-args` holding a JSON array (for typed / null / mixed args; string
20 + * elements may reference the data-a* values via `@a1`, `@a2`, …).
21 + * - Sentinel arg values, substituted before the call, preserve existing method
22 + * signatures that took `event`/`this`:
23 + * `@event` → the DOM event, `@el` → the target element,
24 + * `@value` → the element's `.value`, `@aN` → `data-aN`.
25 + * So `onclick="ctx.showTask(event, '${id}')"` becomes
26 + * `data-act="ctx.showTask" data-a1="@event" data-a2="123"` with no change to
27 + * `showTask(event, id)`.
28 + * - `change` / `input` with *no* explicit args pass the element's value:
29 + * `data-change="foo"` → `foo(el.value)`, matching `onchange="foo(this.value)"`.
30 + * - Nested clickables: the innermost `[data-act]` wins via `closest()`, so a row
31 + * and a button inside it can both carry `data-act` and only the button fires —
32 + * no `event.stopPropagation()` needed.
33 + * - The dispatcher never appends its own context argument, so a method with
34 + * optional trailing params is never surprised by an extra value.
27 35 */
28 36
29 37 (function () {
@@ -40,22 +48,35 @@
40 48 return typeof obj === 'function' ? obj : null;
41 49 }
42 50
43 - /** Collect positional args: `data-args` JSON array, else data-a1..aN. */
44 - function argsFrom(el) {
51 + /** Substitute a sentinel string with the live event/element value. */
52 + function subst(v, event, el) {
53 + if (typeof v !== 'string' || v.charCodeAt(0) !== 64 /* '@' */) return v;
54 + if (v === '@event') return event;
55 + if (v === '@el') return el;
56 + if (v === '@value') return el.value;
57 + if (v === '@checked') return el.checked;
58 + var m = /^@a(\d+)$/.exec(v);
59 + if (m) return el.dataset['a' + m[1]];
60 + return v;
61 + }
62 +
63 + /** Build the argument list for a handler element. */
64 + function argsFrom(el, event, valueFirst) {
65 + var raw;
45 66 if (el.dataset.args !== undefined) {
46 - try { return JSON.parse(el.dataset.args); } catch (e) { return []; }
67 + try { raw = JSON.parse(el.dataset.args); } catch (e) { raw = []; }
68 + } else {
69 + raw = [];
70 + var i = 1, v;
71 + while ((v = el.dataset['a' + i]) !== undefined) { raw.push(v); i++; }
72 + if (raw.length === 0 && valueFirst) return [el.value];
47 73 }
48 - var out = [], i = 1, v;
49 - while ((v = el.dataset['a' + i]) !== undefined) { out.push(v); i++; }
50 - return out;
74 + return raw.map(function (x) { return subst(x, event, el); });
51 75 }
52 76
53 77 /**
54 78 * Register a delegated listener for `eventType`, dispatching elements that
55 79 * carry `data-<attr>`.
56 - * @param {string} eventType DOM event name.
57 - * @param {string} attr dataset key holding the method path.
58 - * @param {{valueFirst?:boolean, preventDefault?:boolean}} [opts]
59 80 */
60 81 function bind(eventType, attr, opts) {
61 82 opts = opts || {};
@@ -67,10 +88,8 @@
67 88 if (!el) return;
68 89 var fn = resolve(el.dataset[attr]);
69 90 if (!fn) return;
70 - var args = argsFrom(el);
71 - if (opts.valueFirst) args = [el.value].concat(args);
72 91 if (opts.preventDefault) event.preventDefault();
73 - fn.apply(el, args.concat([{ event: event, el: el }]));
92 + fn.apply(el, argsFrom(el, event, opts.valueFirst));
74 93 });
75 94 }
76 95
@@ -81,4 +100,10 @@
81 100 bind('keydown', 'keydown');
82 101 bind('contextmenu', 'contextmenu');
83 102 bind('mousedown', 'mousedown');
103 + bind('dragstart', 'dragstart');
104 + bind('dragover', 'dragover');
105 + bind('drop', 'drop');
106 + // `mouseenter` does not bubble, so delegate via `mouseover` (which does).
107 + // Handlers should be idempotent per target element.
108 + bind('mouseover', 'mouseenter');
84 109 })();
@@ -28,14 +28,15 @@
28 28 * @param {Object} opts
29 29 * @param {string} opts.formId - Form element ID
30 30 * @param {string} opts.idPrefix - ID prefix for inputs (e.g. 'acct' or 'edit-acct')
31 - * @param {string} opts.onSubmit - Form onsubmit attribute value
31 + * @param {string} opts.submitAct - Delegated submit action dot-path
32 + * @param {string} [opts.submitId] - Optional id arg for the submit action
32 33 * @param {Object} opts.values - Current field values (empty strings / defaults for add)
33 34 * @param {boolean} opts.isEdit - Whether this is the edit form
34 35 * @param {string} opts.submitLabel - Submit button label
35 36 * @returns {string} HTML string for the form
36 37 */
37 38 function buildAccountFormHtml(opts) {
38 - const { formId, idPrefix, onSubmit, values, isEdit, submitLabel } = opts;
39 + const { formId, idPrefix, submitAct, submitId, values, isEdit, submitLabel } = opts;
39 40
40 41 const passwordLabel = isEdit ? 'Password (leave empty to keep current)' : 'Password';
41 42 const passwordRequired = isEdit ? '' : 'required';
@@ -76,7 +77,7 @@
76 77 });
77 78
78 79 return `
79 - <form id="${formId}" onsubmit="${escAttr(onSubmit)}">
80 + <form id="${formId}" data-submit="${escAttr(submitAct)}" data-a1="@event"${submitId ? ` data-a2="${escAttr(submitId)}"` : ''}>
80 81 ${ff({ kind: 'text', name: 'account_name', id: `${idPrefix}-name`, label: 'Account Name', value: values.accountName || '', placeholder: 'Personal, Work, etc.', required: true })}
81 82 <div class="form-group">
82 83 <label class="form-label" for="${idPrefix}-email">Email Address</label>
@@ -88,7 +89,7 @@
88 89 ${ff({ kind: 'password', name: 'password', id: `${idPrefix}-password`, label: passwordLabel, placeholder: passwordPlaceholder, required: !isEdit })}
89 90 ${ff({ kind: 'text', name: 'archive_folder_name', id: `${idPrefix}-archive-folder`, label: 'Archive Folder Name', value: values.archiveFolderName || 'Archive', placeholder: 'Archive', hint: archiveHint })}
90 91 ${ff({ kind: 'textarea', name: 'email_signature', id: `${idPrefix}-signature`, label: 'Email Signature', value: values.emailSignature || '', placeholder: '-- \nYour Name', hint: 'Appended to outbound emails. Plain text only.', attrs: { rows: 3 } })}
91 - <button type="button" class="form-more-toggle ${advancedExpanded}" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden');">Advanced settings</button>
92 + <button type="button" class="form-more-toggle ${advancedExpanded}" data-act="ui.toggleExpand" data-a1="@el">Advanced settings</button>
92 93 <div class="${advancedHidden}">
93 94 <div class="form-grid-2">
94 95 ${ff({ kind: 'text', name: 'imap_server', id: `${idPrefix}-imap-server`, label: 'IMAP Server', value: values.imapServer || '', placeholder: 'imap.example.com', required: true })}
@@ -108,7 +109,7 @@
108 109 ${ff({ kind: 'select', name: 'sync_interval_minutes', id: `${idPrefix}-sync-interval`, label: 'Auto-sync Interval', options: syncIntervalOpts, hint: 'Automatically check for new emails at this interval.' })}
109 110 </div>
110 111 <div class="form-actions">
111 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.refreshAccountsView()">Cancel</button>
112 + <button type="button" class="btn btn-secondary" data-act="emails.refreshAccountsView">Cancel</button>
112 113 <button type="submit" class="btn btn-primary">${esc(submitLabel)}</button>
113 114 </div>
114 115 </form>
@@ -255,8 +256,8 @@
255 256 : '';
256 257
257 258 const editBtn = isOAuth
258 - ? `<button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.reconnectOAuth('${escArg(a.id)}')">Reconnect</button>`
259 - : `<button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.editAccount('${escArg(a.id)}')">Edit</button>`;
259 + ? `<button class="btn btn-sm btn-secondary" data-act="emails.reconnectOAuth" data-a1="${escAttr(a.id)}">Reconnect</button>`
260 + : `<button class="btn btn-sm btn-secondary" data-act="emails.editAccount" data-a1="${escAttr(a.id)}">Edit</button>`;
260 261
261 262 return `
262 263 <div class="account-row">
@@ -267,12 +268,12 @@
267 268 <div class="account-row-sync">Last sync: ${a.lastSyncFormatted}</div>
268 269 </div>
269 270 ${editBtn}
270 - <button class="btn btn-sm btn-danger" onclick="GoingsOn.emails.deleteAccount('${escArg(a.id)}')" aria-label="Delete account">&times;</button>
271 + <button class="btn btn-sm btn-danger" data-act="emails.deleteAccount" data-a1="${escAttr(a.id)}" aria-label="Delete account">&times;</button>
271 272 </div>
272 273 <div class="account-row-quick">
273 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.testAccount('${escArg(a.id)}')">Test</button>
274 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.syncAccount('${escArg(a.id)}', false)">Sync New</button>
275 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.syncAccount('${escArg(a.id)}', true)">Full Sync</button>
274 + <button class="btn btn-sm btn-secondary" data-act="emails.testAccount" data-a1="${escAttr(a.id)}">Test</button>
275 + <button class="btn btn-sm btn-secondary" data-act="emails.syncAccount" data-a1="${escAttr(a.id)}" data-args='["@a1", false]'>Sync New</button>
276 + <button class="btn btn-sm btn-secondary" data-act="emails.syncAccount" data-a1="${escAttr(a.id)}" data-args='["@a1", true]'>Full Sync</button>
276 277 </div>
277 278 </div>
278 279 `;
@@ -288,9 +289,9 @@
288 289 ${accountsList}
289 290 </div>
290 291 <div class="form-actions">
291 - <button type="button" class="btn btn-primary" onclick="GoingsOn.emails.openAddAccountModal()">+ Add Account</button>
292 + <button type="button" class="btn btn-primary" data-act="emails.openAddAccountModal">+ Add Account</button>
292 293 <div class="form-actions-spacer"></div>
293 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
294 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Close</button>
294 295 </div>
295 296 `;
296 297 GoingsOn.ui.openModal('Email Accounts', content);
@@ -311,7 +312,7 @@
311 312 ${accountsList}
312 313 </div>
313 314 <div class="form-actions">
314 - <button type="button" class="btn btn-primary" onclick="GoingsOn.emails.openAddAccountModal()">+ Add Account</button>
315 + <button type="button" class="btn btn-primary" data-act="emails.openAddAccountModal">+ Add Account</button>
315 316 </div>
316 317 </div>
317 318 `;
@@ -365,7 +366,7 @@
365 366 <div class="oauth-block">
366 367 <div class="oauth-buttons">
367 368 ${oauthProviders.map(p => `
368 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.startOAuth('${escArg(p.id)}')">
369 + <button type="button" class="btn btn-secondary" data-act="emails.startOAuth" data-a1="${escAttr(p.id)}">
369 370 ${esc(p.name)}
370 371 </button>
371 372 `).join('')}
@@ -378,7 +379,7 @@
378 379 const formHtml = buildAccountFormHtml({
379 380 formId: 'email-account-form',
380 381 idPrefix: 'acct',
381 - onSubmit: "GoingsOn.emails.createAccount(event)",
382 + submitAct: 'emails.createAccount',
382 383 values: {},
383 384 isEdit: false,
384 385 submitLabel: 'Add Account',
@@ -432,7 +433,7 @@
432 433 const content = buildAccountFormHtml({
433 434 formId: 'edit-email-account-form',
434 435 idPrefix: 'edit-acct',
435 - onSubmit: `GoingsOn.emails.updateAccount(event, '${escArg(id)}')`,
436 + submitAct: 'emails.updateAccount', submitId: id,
436 437 values: account,
437 438 isEdit: true,
438 439 submitLabel: 'Save Changes',
@@ -531,7 +532,7 @@
531 532 ${foldersHtml}
532 533 </div>
533 534 <div class="form-actions">
534 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.refreshAccountsView()">Back</button>
535 + <button type="button" class="btn btn-secondary" data-act="emails.refreshAccountsView">Back</button>
535 536 </div>
536 537 `;
537 538 GoingsOn.ui.openModal('Connection Test Results', content);
@@ -575,7 +576,7 @@
575 576 ` : ''}
576 577 </div>
577 578 <div class="form-actions">
578 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.refreshAccountsView()">Back</button>
579 + <button type="button" class="btn btn-secondary" data-act="emails.refreshAccountsView">Back</button>
579 580 </div>
580 581 `;
581 582 GoingsOn.ui.openModal('Sync Results', content);
@@ -662,7 +663,7 @@
662 663 A browser window should have opened. Please sign in and authorize the app.
663 664 </div>
664 665 <div class="spinner oauth-waiting-spinner"></div>
665 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.emails.cancelOAuth()">Cancel</button>
666 + <button type="button" class="btn btn-secondary" data-act="emails.cancelOAuth">Cancel</button>
666 667 </div>
667 668 `;
668 669 GoingsOn.ui.openModal('Connecting Account', content);
@@ -12,6 +12,7 @@
12 12
13 13 const esc = GoingsOn.utils.escapeHtml;
14 14 const escArg = GoingsOn.utils.escapeHandlerArg;
15 + const escAttr = GoingsOn.utils.escapeAttrValue;
15 16
16 17 /**
17 18 * Open the email compose interface (new window on desktop, modal on mobile).
@@ -79,7 +80,7 @@ function openComposeModal(prefill) {
79 80 };
80 81
81 82 const content = `
82 - <form id="compose-modal-form" onsubmit="event.preventDefault(); return false;">
83 + <form id="compose-modal-form" data-submit="ui.noop">
83 84 <span id="reply-indicator" class="reply-indicator"></span>
84 85 ${fieldsHtml}
85 86 <div class="form-actions" style="margin-top: 0.75rem;">
@@ -229,7 +230,7 @@ async function openDraftsModal() {
229 230 const subject = d.subject || '(no subject)';
230 231 return `
231 232 <div class="email-item email-draft-item"
232 - onclick="GoingsOn.emails.openDraft('${escArg(d.id)}')">
233 + data-act="emails.openDraft" data-a1="${escAttr(d.id)}">
233 234 <div class="email-draft-subject">${esc(subject)}</div>
234 235 <div class="email-draft-meta">To: ${esc(to)} · ${d.receivedFormatted}</div>
235 236 </div>
@@ -33,17 +33,17 @@ function renderEmailItem(thread, index) {
33 33 return `
34 34 <div class="email-item email-item-with-checkbox ${thread.hasUnread ? 'unread' : ''} ${isSnoozed ? 'email-snoozed' : ''}"
35 35 data-id="${escAttr(e.id)}"
36 - oncontextmenu="GoingsOn.contextMenus.showEmail(event, '${escArg(e.id)}')"
36 + data-contextmenu="contextMenus.showEmail" data-a1="@event" data-a2="${escAttr(e.id)}"
37 37 data-email-archived="${e.isArchived}" data-email-read="${e.isRead}"
38 38 data-email-snoozed="${isSnoozed}"
39 39 tabindex="0" role="listitem" aria-label="Email from ${esc(e.from)}">
40 - <div class="email-checkbox-cell" onclick="event.stopPropagation();">
40 + <div class="email-checkbox-cell" data-act="ui.noop">
41 41 <input type="checkbox" class="bulk-checkbox" data-id="${escAttr(e.id)}"
42 42 ${isSelected ? 'checked' : ''}
43 - onchange="GoingsOn.emails.toggleSelection('${escArg(e.id)}', this, event)"
43 + data-change="emails.toggleSelection" data-a1="${escAttr(e.id)}" data-a2="@el" data-a3="@event"
44 44 aria-label="Select email">
45 45 </div>
46 - <div class="email-content" onclick="GoingsOn.emails.open('${escArg(e.id)}')" role="button">
46 + <div class="email-content" data-act="emails.open" data-a1="${escAttr(e.id)}" role="button">
47 47 <div class="email-header">
48 48 <span class="email-from">${esc(e.from)}</span>
49 49 ${threadBadge}
@@ -53,7 +53,7 @@ function renderEmailItem(thread, index) {
53 53 <div class="email-subject">${esc(e.subject)}${labelBadges ? ' ' + labelBadges : ''}</div>
54 54 <div class="email-preview">${esc(e.bodyPreview)}...</div>
55 55 </div>
56 - <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEmail(event, '${escArg(e.id)}')" title="Actions" aria-label="Email actions">&#x22EE;</button>
56 + <button class="btn-icon kebab-btn" data-act="contextMenus.showEmail" data-a1="@event" data-a2="${escAttr(e.id)}" title="Actions" aria-label="Email actions">&#x22EE;</button>
57 57 </div>
58 58 `;
59 59 }
@@ -172,8 +172,8 @@
172 172 if (response.total === 0) {
173 173 const hasAccounts = GoingsOn.getEmailAccountsCache().length > 0;
174 174 container.innerHTML = hasAccounts
175 - ? GoingsOn.ui.renderEmptyState('No emails yet.', 'Compose', 'GoingsOn.emails.openCompose()', 'emails')
176 - : GoingsOn.ui.renderEmptyState('Set up an email account to get started.', 'Add Account', 'GoingsOn.emails.openAccountsModal()', 'inbox');
175 + ? GoingsOn.ui.renderEmptyState('No emails yet.', 'Compose', 'emails.openCompose', 'emails')
176 + : GoingsOn.ui.renderEmptyState('Set up an email account to get started.', 'Add Account', 'emails.openAccountsModal', 'inbox');
177 177 // Hide pagination
178 178 const paginationEl = document.getElementById('email-pagination');
179 179 if (paginationEl) paginationEl.classList.add('hidden');
@@ -207,7 +207,7 @@
207 207 }
208 208 GoingsOn.cache.markLoaded('emails');
209 209 } catch (err) {
210 - container.innerHTML = `<div class="loading loading--error">Failed to load emails. <button class="btn-link" onclick="GoingsOn.emails.load()">Try again</button></div>`;
210 + container.innerHTML = `<div class="loading loading--error">Failed to load emails. <button class="btn-link" data-act="emails.load">Try again</button></div>`;
211 211 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load emails'), 'error', {
212 212 action: { label: 'Retry', fn: load },
213 213 duration: 8000,
@@ -273,14 +273,14 @@
273 273 // Build action buttons for the most recent email
274 274 const latestEmail = threadEmails[threadEmails.length - 1];
275 275 const archiveBtn = latestEmail.isArchived
276 - ? `<button class="btn btn-secondary" onclick="GoingsOn.emails.unarchive('${escArg(latestEmail.id)}')">Unarchive</button>`
277 - : `<button class="btn btn-secondary" onclick="GoingsOn.emails.archive('${escArg(latestEmail.id)}')">Archive</button>`;
276 + ? `<button class="btn btn-secondary" data-act="emails.unarchive" data-a1="${escAttr(latestEmail.id)}">Unarchive</button>`
277 + : `<button class="btn btn-secondary" data-act="emails.archive" data-a1="${escAttr(latestEmail.id)}">Archive</button>`;
278 278
279 279 // Use pre-computed field from backend
280 280 const isSnoozed = latestEmail.isSnoozed;
281 281 const snoozeBtn = isSnoozed
282 - ? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('email', '${escArg(latestEmail.id)}')">Unsnooze</button>`
283 - : `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('email', '${escArg(latestEmail.id)}')">Snooze</button>`;
282 + ? `<button class="btn btn-secondary" data-act="snooze.unsnooze" data-a1="email" data-a2="${escAttr(latestEmail.id)}">Unsnooze</button>`
283 + : `<button class="btn btn-secondary" data-act="snooze.openModal" data-a1="email" data-a2="${escAttr(latestEmail.id)}">Snooze</button>`;
284 284
285 285 // Look up contact from sender email
286 286 const parsed = GoingsOn.utils.parseEmailAddress(email.from);
@@ -306,7 +306,7 @@
306 306 <span class="email-sender-name">${esc(senderContact.displayName || senderContact.display_name)}</span>
307 307 ${company ? `<span class="email-sender-company">${company}</span>` : ''}
308 308 </div>
309 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.contacts.open('${escArg(senderContact.id)}')">View Contact</button>
309 + <button class="btn btn-sm btn-secondary" data-act="ui.closeModalThen" data-a1="contacts.open" data-a2="${escAttr(senderContact.id)}">View Contact</button>
310 310 </div>
311 311 `;
312 312 } else if (parsed.email) {
@@ -316,7 +316,7 @@
316 316 <div class="email-sender-info">
317 317 <span class="email-sender-name">${esc(parsed.name || parsed.email)}</span>
318 318 </div>
319 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.createContactFromSender('${escArg(id)}')">+ Save Contact</button>
319 + <button class="btn btn-sm btn-secondary" data-act="emails.createContactFromSender" data-a1="${escAttr(id)}">+ Save Contact</button>
320 320 </div>
321 321 `;
322 322 }
@@ -335,8 +335,8 @@
335 335 <span class="attachment-filename email-attachment-name"
336 336 title="${escAttr(a.filename)}">${esc(a.filename)}</span>
337 337 <span class="email-attachment-size">${esc(a.sizeFormatted)}</span>
338 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.openBlob('${escArg(a.blobHash)}', '${escArg(a.filename)}')" title="Open">Open</button>
339 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.saveBlob('${escArg(a.blobHash)}', '${escArg(a.filename)}')" title="Save">Save</button>
338 + <button class="btn btn-sm btn-secondary" data-act="emails.openBlob" data-a1="${escAttr(a.blobHash)}" data-a2="${escAttr(a.filename)}" title="Open">Open</button>
339 + <button class="btn btn-sm btn-secondary" data-act="emails.saveBlob" data-a1="${escAttr(a.blobHash)}" data-a2="${escAttr(a.filename)}" title="Save">Save</button>
340 340 </div>
341 341 `;
342 342 }).join('');
@@ -360,7 +360,7 @@
360 360 const truncatedNotice = e.bodyTruncated
361 361 ? `<div class="email-body-truncated" id="email-trunc-${escAttr(e.id)}">
362 362 <span>Message truncated.</span>
363 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.emails.loadFullBody('${escArg(e.id)}')">Load full message</button>
363 + <button class="btn btn-sm btn-secondary" data-act="emails.loadFullBody" data-a1="${escAttr(e.id)}">Load full message</button>
364 364 </div>`
365 365 : '';
366 366
@@ -398,34 +398,34 @@
398 398 ${threadContent}
399 399 </div>
400 400 <div class="form-actions email-actions-bar">
401 - <button class="btn btn-primary" onclick="GoingsOn.emails.reply('${escArg(latestEmail.id)}')">Reply</button>
402 - <button class="btn btn-secondary" onclick="GoingsOn.emails.replyAll('${escArg(latestEmail.id)}')">Reply All</button>
403 - <button class="btn btn-secondary" onclick="GoingsOn.emails.forward('${escArg(latestEmail.id)}')">Forward</button>
404 - <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.emails.delete('${escArg(latestEmail.id)}')">Delete</button>
401 + <button class="btn btn-primary" data-act="emails.reply" data-a1="${escAttr(latestEmail.id)}">Reply</button>
402 + <button class="btn btn-secondary" data-act="emails.replyAll" data-a1="${escAttr(latestEmail.id)}">Reply All</button>
403 + <button class="btn btn-secondary" data-act="emails.forward" data-a1="${escAttr(latestEmail.id)}">Forward</button>
404 + <button class="btn btn-secondary text-accent-red" data-act="emails.delete" data-a1="${escAttr(latestEmail.id)}">Delete</button>
405 405 ${archiveBtn}
406 406 ${snoozeBtn}
407 - <button class="btn btn-secondary" onclick="GoingsOn.emails.createTaskFromEmail('${escArg(latestEmail.id)}')">Create Task</button>
407 + <button class="btn btn-secondary" data-act="emails.createTaskFromEmail" data-a1="${escAttr(latestEmail.id)}">Create Task</button>
408 408 <div class="dropdown" style="position: relative;">
409 - <button class="btn btn-secondary" onclick="this.nextElementSibling.classList.toggle('show')">
409 + <button class="btn btn-secondary" data-act="ui.toggleMenu" data-a1="@el">
410 410 Actions ▾
411 411 </button>
412 412 <div class="dropdown-menu">
413 - <button class="dropdown-item" onclick="GoingsOn.emails.createTaskFromEmail('${escArg(latestEmail.id)}'); this.parentElement.classList.remove('show');">
413 + <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createTaskFromEmail" data-a3="${escAttr(latestEmail.id)}">
414 414 Convert to Task
415 415 </button>
416 - <button class="dropdown-item" onclick="GoingsOn.emails.createEventFromEmail('${escArg(latestEmail.id)}'); this.parentElement.classList.remove('show');">
416 + <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.createEventFromEmail" data-a3="${escAttr(latestEmail.id)}">
417 417 Convert to Event
418 418 </button>
419 - <button class="dropdown-item" onclick="GoingsOn.emails.editLabels('${escArg(latestEmail.id)}', ${escAttrVal(JSON.stringify(latestEmail.labels || []))}); this.parentElement.classList.remove('show');">
419 + <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.editLabels" data-a3="${escAttr(latestEmail.id)}" data-a4="${escAttr(JSON.stringify(latestEmail.labels || []))}">
420 420 Edit Labels
421 421 </button>
422 - <button class="dropdown-item" onclick="GoingsOn.emails.moveToFolder('${escArg(latestEmail.id)}'); this.parentElement.classList.remove('show');">
422 + <button class="dropdown-item" data-act="ui.menuAction" data-a1="@el" data-a2="emails.moveToFolder" data-a3="${escAttr(latestEmail.id)}">
423 423 Move to Folder
424 424 </button>
425 425 </div>
426 426 </div>
427 427 <div class="flex-1"></div>
428 - <button class="btn btn-secondary" onclick="GoingsOn.emails.openInBrowser('${escArg(latestEmail.id)}')" title="Open in browser">Open in Browser</button>
428 + <button class="btn btn-secondary" data-act="emails.openInBrowser" data-a1="${escAttr(latestEmail.id)}" title="Open in browser">Open in Browser</button>
429 429 </div>
430 430 </div>
431 431 `;
@@ -809,7 +809,7 @@
809 809 async function editLabels(emailId, currentLabels) {
810 810 const existing = await GoingsOn.api.emails.listLabels();
811 811 const content = `
812 - <form id="label-form" onsubmit="event.preventDefault(); GoingsOn.emails._saveLabels('${escArg(emailId)}');">
812 + <form id="label-form" data-submit="emails._saveLabels" data-a1="${escAttr(emailId)}">
813 813 <div class="form-group">
814 814 <label class="form-label">Labels (comma-separated)</label>
815 815 <input type="text" class="form-input" id="label-input" value="${escAttrVal((currentLabels || []).join(', '))}"
@@ -817,7 +817,7 @@
817 817 ${existing.length > 0 ? `<div class="label-existing-line">Existing: ${existing.map(l => esc(l)).join(', ')}</div>` : ''}
818 818 </div>
819 819 <div class="form-actions">
820 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
820 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
821 821 <button type="submit" class="btn btn-primary">Save</button>
822 822 </div>
823 823 </form>
@@ -855,7 +855,7 @@
855 855 const folders = await GoingsOn.api.emails.listFolders();
856 856 // Also try to get IMAP folders from account
857 857 const content = `
858 - <form onsubmit="event.preventDefault(); GoingsOn.emails._doMoveToFolder('${escArg(emailId)}');">
858 + <form data-submit="emails._doMoveToFolder" data-a1="${escAttr(emailId)}">
859 859 <div class="form-group">
860 860 <label class="form-label">Move to folder</label>
861 861 <input type="text" class="form-input" id="move-folder-input" placeholder="INBOX, Archive, Sent, ..."
@@ -865,7 +865,7 @@
865 865 </datalist>
866 866 </div>
867 867 <div class="form-actions">
868 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
868 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
869 869 <button type="submit" class="btn btn-primary">Move</button>
870 870 </div>
871 871 </form>
@@ -958,7 +958,7 @@
958 958 if (emailScroller) { emailScroller.destroy(); emailScroller = null; }
959 959 container.innerHTML = response.results.map(r => `
960 960 <div class="email-item" data-id="${escAttr(r.id)}"
961 - onclick="GoingsOn.emails.open('${escArg(r.id)}')"
961 + data-act="emails.open" data-a1="${escAttr(r.id)}"
962 962 tabindex="0" role="listitem">
963 963 <div class="email-content">
964 964 <div class="email-header">
@@ -122,13 +122,13 @@
122 122 if (!isCurrentMonth) classes.push('other-month');
123 123 if (isToday) classes.push('today');
124 124
125 - html += `<div class="${classes.join(' ')}" data-date="${escAttr(dateKey)}" onclick="GoingsOn.eventsCalendar.toggleDayDetail('${escArg(dateKey)}')">`;
125 + html += `<div class="${classes.join(' ')}" data-date="${escAttr(dateKey)}" data-act="eventsCalendar.toggleDayDetail" data-a1="${escAttr(dateKey)}">`;
126 126 html += `<div class="cal-month-cell-header"><span class="cal-day-number">${cursor.getDate()}</span></div>`;
127 127
128 128 const maxShow = 3;
129 129 dayEvents.slice(0, maxShow).forEach(e => {
130 130 const blockClass = e.blockType ? `block-${e.blockType}` : '';
131 - html += `<div class="cal-event-chip ${blockClass}" onclick="event.stopPropagation(); GoingsOn.events.open('${escArg(e.id)}')" title="${escAttrVal(e.title)}">${esc(truncate(e.title, 18))}</div>`;
131 + html += `<div class="cal-event-chip ${blockClass}" data-act="events.open" data-a1="${escAttr(e.id)}" title="${escAttrVal(e.title)}">${esc(truncate(e.title, 18))}</div>`;
132 132 });
133 133 if (dayEvents.length > maxShow) {
134 134 html += `<div class="cal-event-more">+${dayEvents.length - maxShow} more</div>`;
@@ -166,7 +166,7 @@
166 166 } else {
167 167 dayEvents.forEach(e => {
168 168 const blockClass = e.blockType ? `block-${e.blockType}` : '';
169 - html += `<div class="cal-day-detail-event ${blockClass}" onclick="GoingsOn.events.open('${escArg(e.id)}')">
169 + html += `<div class="cal-day-detail-event ${blockClass}" data-act="events.open" data-a1="${escAttr(e.id)}">
170 170 <span class="cal-detail-time">${esc(e.timeFormatted)}</span>
171 171 <span class="cal-detail-title">${esc(e.title)}</span>
172 172 ${e.location ? `<span class="cal-detail-location">${esc(e.location)}</span>` : ''}
@@ -249,7 +249,7 @@
249 249 html += '<div class="cal-week-allday-cell">';
250 250 dayEvts.forEach(e => {
251 251 const blockClass = e.blockType ? `block-${e.blockType}` : '';
252 - html += `<div class="cal-event-chip ${blockClass}" onclick="GoingsOn.events.open('${escArg(e.id)}')" title="${escAttrVal(e.title)}">${esc(truncate(e.title, 14))}</div>`;
252 + html += `<div class="cal-event-chip ${blockClass}" data-act="events.open" data-a1="${escAttr(e.id)}" title="${escAttrVal(e.title)}">${esc(truncate(e.title, 14))}</div>`;
253 253 });
254 254 html += '</div>';
255 255 adCursor.setDate(adCursor.getDate() + 1);
@@ -293,7 +293,7 @@
293 293 const heightPx = Math.max((durationMinutes / 15) * SLOT_HEIGHT, SLOT_HEIGHT);
294 294 const blockClass = e.blockType ? `block-${e.blockType}` : '';
295 295
296 - html += `<div class="cal-week-event ${blockClass}" style="top: ${topPx}px; height: ${heightPx}px;" onclick="GoingsOn.events.open('${escArg(e.id)}')" title="${escAttrVal(e.title + ' ' + e.timeFormatted)}">
296 + html += `<div class="cal-week-event ${blockClass}" style="top: ${topPx}px; height: ${heightPx}px;" data-act="events.open" data-a1="${escAttr(e.id)}" title="${escAttrVal(e.title + ' ' + e.timeFormatted)}">
297 297 <div class="cal-week-event-title">${esc(truncate(e.title, 20))}</div>
298 298 <div class="cal-week-event-time">${esc(e.timeFormatted)}</div>
299 299 </div>`;
@@ -347,7 +347,7 @@
347 347 html += '<div class="cal-mobile-allday">';
348 348 allDayEvts.forEach(e => {
349 349 const blockClass = e.blockType ? `block-${e.blockType}` : '';
350 - html += `<div class="cal-event-chip ${blockClass}" onclick="GoingsOn.events.open('${escArg(e.id)}')">${esc(truncate(e.title, 30))}</div>`;
350 + html += `<div class="cal-event-chip ${blockClass}" data-act="events.open" data-a1="${escAttr(e.id)}">${esc(truncate(e.title, 30))}</div>`;
351 351 });
352 352 html += '</div>';
353 353 }
@@ -374,7 +374,7 @@
374 374 const topPx = ((startMinutes - HOURS_START * 60) / 15) * SLOT_HEIGHT;
375 375 const heightPx = Math.max((durationMinutes / 15) * SLOT_HEIGHT, SLOT_HEIGHT);
376 376 const blockClass = e.blockType ? `block-${e.blockType}` : '';
377 - html += `<div class="cal-week-event ${blockClass}" style="top: ${topPx}px; height: ${heightPx}px;" onclick="GoingsOn.events.open('${escArg(e.id)}')">
377 + html += `<div class="cal-week-event ${blockClass}" style="top: ${topPx}px; height: ${heightPx}px;" data-act="events.open" data-a1="${escAttr(e.id)}">
378 378 <div class="cal-week-event-title">${esc(truncate(e.title, 30))}</div>
379 379 <div class="cal-week-event-time">${esc(e.timeFormatted)}</div>
380 380 </div>`;
@@ -305,7 +305,7 @@
305 305 pastSection.classList.add('hidden');
306 306 recurringSection.classList.add('hidden');
307 307 if (futureHeading) futureHeading.classList.add('hidden');
308 - upcomingContainer.innerHTML = GoingsOn.ui.renderEmptyState('No events scheduled.', 'Add Event', 'GoingsOn.events.openNew()', 'events');
308 + upcomingContainer.innerHTML = GoingsOn.ui.renderEmptyState('No events scheduled.', 'Add Event', 'events.openNew', 'events');
309 309 [upcomingEventsScroller, pastEventsScroller, recurringEventsScroller].forEach(s => s && s.destroy());
310 310 upcomingEventsScroller = pastEventsScroller = recurringEventsScroller = null;
311 311 return;
@@ -397,7 +397,7 @@
397 397 }
398 398 GoingsOn.cache.markLoaded('events');
399 399 } catch (err) {
400 - 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>`;
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 401 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load events'), 'error', {
402 402 action: { label: 'Retry', fn: () => { GoingsOn.cache.invalidate('events'); load(); } },
403 403 duration: 8000,
@@ -424,15 +424,15 @@
424 424 return `
425 425 <div class="event-row-virtual event-recurring"
426 426 data-id="${escAttr(e.id)}"
427 - onclick="GoingsOn.events.open('${escArg(e.id)}')"
428 - oncontextmenu="GoingsOn.contextMenus.showEvent(event, '${escArg(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 429 tabindex="0" role="row">
430 430 <div class="event-cell event-cell-date"><span class="event-recurrence-pattern">${esc(patternLabel)}</span></div>
431 431 <div class="event-cell event-cell-time">${e.timeFormatted}</div>
432 432 <div class="event-cell event-cell-title">${esc(displayTitle)}</div>
433 433 <div class="event-cell event-cell-location">${e.location ? esc(e.location) : '-'}</div>
434 - <div class="event-cell" style="text-align: right;" onclick="event.stopPropagation();">
435 - <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEvent(event, '${escArg(e.id)}')" title="Actions" aria-label="Event actions">&#x22EE;</button>
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 436 </div>
437 437 </div>
438 438 `;
@@ -460,12 +460,12 @@
460 460 ${dateHeader}
461 461 <div class="event-row-virtual ${e.isPast || isPast ? 'event-past' : ''}"
462 462 data-id="${escAttr(e.id)}"
463 - onclick="GoingsOn.events.open('${escArg(e.id)}')"
464 - oncontextmenu="GoingsOn.contextMenus.showEvent(event, '${escArg(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 465 tabindex="0" role="row">
466 466 <div class="event-cell event-cell--shrink">
467 467 <input type="checkbox" class="bulk-checkbox event-select-cb" data-id="${escAttr(e.id)}"
468 - onclick="event.stopPropagation(); GoingsOn.events.toggleEventSelection('${escArg(e.id)}', event)"
468 + data-act="events.toggleEventSelection" data-a1="${escAttr(e.id)}" data-a2="@event"
469 469 aria-label="Select event">
470 470 </div>
471 471 <div class="event-cell event-cell-date">
@@ -475,8 +475,8 @@
475 475 <div class="event-cell event-cell-time">${e.timeFormatted}</div>
476 476 <div class="event-cell event-cell-title">${esc(displayTitle)}</div>
477 477 <div class="event-cell event-cell-location">${e.location ? esc(e.location) : '-'}</div>
478 - <div class="event-cell" style="text-align: right;" onclick="event.stopPropagation();">
479 - <button class="btn-icon kebab-btn" onclick="event.stopPropagation(); GoingsOn.contextMenus.showEvent(event, '${escArg(e.id)}')" title="Actions" aria-label="Event actions">&#x22EE;</button>
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 480 </div>
481 481 </div>
482 482 `;
@@ -581,8 +581,8 @@
581 581 ? GoingsOn.snooze.formatTime(event.snoozedUntil)
582 582 : null;
583 583 const snoozeButton = event.isSnoozed
584 - ? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('event', '${escArg(id)}')">Unsnooze</button>`
585 - : `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('event', '${escArg(id)}')">Snooze</button>`;
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 586 const snoozeStatus = snoozeUntilLabel
587 587 ? `<p><strong>Snoozed until:</strong> ${esc(snoozeUntilLabel)}</p>`
588 588 : '';
@@ -603,11 +603,11 @@
603 603 ${reminderStatus}
604 604 </div>
605 605 <div class="form-actions">
606 - <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.events.delete('${escArg(id)}')">Delete</button>
606 + <button class="btn btn-secondary text-accent-red" data-act="events.delete" data-a1="${escAttr(id)}">Delete</button>
607 607 <div class="form-actions-spacer"></div>
608 608 ${snoozeButton}
609 - <button class="btn btn-secondary" onclick="GoingsOn.events.openEdit('${escArg(id)}')">Edit</button>
610 - <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
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 611 </div>
612 612 `;
613 613 GoingsOn.ui.openModal('Event Details', content);
@@ -802,6 +802,8 @@
802 802
803 803 GoingsOn.events = {
804 804 load,
805 + // Invalidate the events cache and reload (error-retry button).
806 + reload: () => { GoingsOn.cache.invalidate('events'); load(); },
805 807 onFilterChange,
806 808 openNew,
807 809 openNewForProject,
@@ -119,8 +119,8 @@
119 119 <div class="backup-item-meta">${dateStr} - ${sizeStr}</div>
120 120 </div>
121 121 <div class="backup-item-actions">
122 - <button class="btn btn-sm btn-secondary" onclick="GoingsOn.export.restoreFromBackup('${escArg(backup.filePath)}')">Restore</button>
123 - <button class="btn btn-sm btn-danger" onclick="GoingsOn.export.deleteBackup('${escArg(backup.filePath)}')">Delete</button>
122 + <button class="btn btn-sm btn-secondary" data-act="export.restoreFromBackup" data-a1="${escAttr(backup.filePath)}">Restore</button>
123 + <button class="btn btn-sm btn-danger" data-act="export.deleteBackup" data-a1="${escAttr(backup.filePath)}">Delete</button>
124 124 </div>
125 125 </div>
126 126 `;
@@ -132,11 +132,11 @@
132 132 </div>
133 133
134 134 <div class="form-actions" style="margin-top: 1.5rem;">
135 - <button class="btn btn-secondary" onclick="GoingsOn.export.createBackup(); GoingsOn.ui.closeModal(); setTimeout(() => GoingsOn.export.openBackupsModal(), 500);">
135 + <button class="btn btn-secondary" data-act="export.backupThenManage">
136 136 Create New Backup
137 137 </button>
138 138 <div class="flex-1"></div>
139 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Close</button>
139 + <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Close</button>
140 140 </div>
141 141 `;
142 142
@@ -283,8 +283,8 @@
283 283 </div>
284 284
285 285 <div class="form-actions form-actions--spaced">
286 - <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.settings.open();">Cancel</button>
287 - <button type="button" class="btn btn-primary" onclick="GoingsOn.export.saveBackupSettings()">Save Settings</button>
286 + <button type="button" class="btn btn-secondary" data-act="ui.closeModalThen" data-a1="settings.open">Cancel</button>
287 + <button type="button" class="btn btn-primary" data-act="export.saveBackupSettings">Save Settings</button>
288 288 </div>
289 289 `;
290 290
@@ -320,6 +320,12 @@
320 320 exportEventsICS,
321 321 createBackup,
322 322 openBackupsModal,
323 + // Create a backup, close the modal, then reopen the backups list.
324 + backupThenManage: async () => {
325 + await createBackup();
326 + GoingsOn.ui.closeModal();
327 + setTimeout(() => openBackupsModal(), 500);
328 + },
323 329 restoreFromBackup,
324 330 deleteBackup,
325 331 openBackupSettingsModal,
@@ -76,7 +76,7 @@ function openFormModal(config) {
76 76 if (hasExtended && field.extended && !inExtended) {
77 77 inExtended = true;
78 78 const expanded = extendedHasValues ? 'expanded' : '';
79 - prefix = `<button type="button" class="form-more-toggle ${expanded}" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden'); this.textContent = this.classList.contains('expanded') ? 'Less options' : 'More options';">${extendedHasValues ? 'Less options' : 'More options'}</button><div class="form-extended-fields ${extendedHasValues ? '' : 'hidden'}">`;
79 + prefix = `<button type="button" class="form-more-toggle ${expanded}" data-act="ui.toggleExpand" data-a1="@el" data-text-expanded="Less options" data-text-collapsed="More options">${extendedHasValues ? 'Less options' : 'More options'}</button><div class="form-extended-fields ${extendedHasValues ? '' : 'hidden'}">`;
80 80 }
81 81
82 82 const groupHtml = GoingsOn.ui.renderFormField({