Skip to main content

max / goingson

17.2 KB · 431 lines History Blame Raw
1 /**
2 * GoingsOn - Bulk Actions Module
3 * Multi-select and bulk operations for tasks & emails
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttrValue;
10 const escArg = GoingsOn.utils.escapeHandlerArg;
11
12 /**
13 * Show or hide the bulk actions bars based on current selection state.
14 */
15 function updateBulkActionsBar() {
16 const taskBar = document.getElementById('task-bulk-actions');
17 const emailBar = document.getElementById('email-bulk-actions');
18 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
19 const selectedEmailIds = GoingsOn.emails?.getSelected?.() || new Set();
20
21 if (taskBar) {
22 if (selectedTaskIds.size > 0) {
23 taskBar.classList.remove('hidden');
24 document.getElementById('task-bulk-count').textContent = `${selectedTaskIds.size} selected`;
25 } else {
26 taskBar.classList.add('hidden');
27 }
28 }
29
30 if (emailBar) {
31 if (selectedEmailIds.size > 0) {
32 emailBar.classList.remove('hidden');
33 document.getElementById('email-bulk-count').textContent = `${selectedEmailIds.size} selected`;
34 } else {
35 emailBar.classList.add('hidden');
36 }
37 }
38 }
39
40 // ============ Bulk Snooze Modal ============
41
42 /**
43 * Open the snooze modal for a set of selected items.
44 * @param {string} itemType - 'tasks' or 'emails'
45 * @param {Set<string>} selectedIds - IDs of items to snooze
46 * @param {Function} snoozeCallback - (until: string) => Promise called with the chosen snooze time
47 */
48 async function openBulkSnoozeModal(itemType, selectedIds, snoozeCallback) {
49 if (selectedIds.size === 0) return;
50
51 // Get pre-computed snooze options from backend
52 const response = await GoingsOn.api.snooze.getOptions();
53 const options = response.options;
54
55 let optionsHtml = `<p class="bulk-modal-prompt">Snooze ${selectedIds.size} ${itemType}:</p>`;
56
57 for (const opt of options) {
58 optionsHtml += `
59 <button class="snooze-option" data-act="bulk._snoozeCallback" data-a1="${escAttr(opt.time)}">
60 <span class="snooze-option-label">${esc(opt.label)}</span>
61 <span class="snooze-option-time">${esc(opt.formatted)}</span>
62 </button>
63 `;
64 }
65
66 const content = `<div class="snooze-options">${optionsHtml}</div>`;
67
68 // Store the callback for when user clicks an option
69 GoingsOn.bulk._snoozeCallback = snoozeCallback;
70
71 GoingsOn.ui.openModal(`Bulk Snooze ${itemType.charAt(0).toUpperCase() + itemType.slice(1)}`, content);
72 }
73
74 // ============ Task Bulk Actions ============
75
76 async function completeTasks() {
77 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
78 if (selectedTaskIds.size === 0) return;
79
80 const ids = Array.from(selectedTaskIds);
81 GoingsOn.cache.invalidate('tasks');
82
83 GoingsOn.ui.bulkActionWithUndo({
84 ids,
85 label: 'Completed',
86 itemType: 'task',
87 apply: (ids) => {
88 const idSet = new Set(ids);
89 const cached = GoingsOn.state.tasks || [];
90 const removed = cached.filter(t => idSet.has(t.id));
91 GoingsOn.state.set('tasks', cached.filter(t => !idSet.has(t.id)));
92 selectedTaskIds.clear();
93 updateBulkActionsBar();
94 return removed;
95 },
96 revert: (removed) => {
97 const current = GoingsOn.state.tasks || [];
98 GoingsOn.state.set('tasks', [...current, ...removed]);
99 },
100 commit: async (ids) => {
101 const results = await Promise.allSettled(ids.map(id => GoingsOn.api.tasks.complete(id)));
102 const failed = results.filter(r => r.status === 'rejected').length;
103 if (failed > 0 && failed < ids.length) {
104 GoingsOn.ui.showToast(`${ids.length - failed} succeeded, ${failed} failed`, 'warning');
105 } else if (failed === ids.length) {
106 const firstErr = results.find(r => r.status === 'rejected');
107 throw firstErr.reason;
108 }
109 GoingsOn.tasks.load();
110 },
111 errorMessage: 'Failed to complete tasks',
112 });
113 }
114
115 function deleteTasks() {
116 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
117 if (selectedTaskIds.size === 0) return;
118
119 const ids = Array.from(selectedTaskIds);
120 GoingsOn.cache.invalidate('tasks');
121
122 GoingsOn.ui.bulkActionWithUndo({
123 ids,
124 label: 'Deleted',
125 itemType: 'task',
126 apply: (ids) => {
127 const idSet = new Set(ids);
128 const cached = GoingsOn.state.tasks || [];
129 const removed = cached.filter(t => idSet.has(t.id));
130 GoingsOn.state.set('tasks', cached.filter(t => !idSet.has(t.id)));
131 selectedTaskIds.clear();
132 updateBulkActionsBar();
133 return removed;
134 },
135 revert: (removed) => {
136 const current = GoingsOn.state.tasks || [];
137 GoingsOn.state.set('tasks', [...current, ...removed]);
138 },
139 commit: async (ids) => {
140 const results = await Promise.allSettled(ids.map(id => GoingsOn.api.tasks.delete(id)));
141 const failed = results.filter(r => r.status === 'rejected').length;
142 if (failed === ids.length) {
143 throw results.find(r => r.status === 'rejected').reason;
144 }
145 if (failed > 0) {
146 GoingsOn.ui.showToast(`${ids.length - failed} deleted, ${failed} failed`, 'warning');
147 }
148 GoingsOn.tasks.load();
149 },
150 errorMessage: 'Failed to delete tasks',
151 });
152 }
153
154 function snoozeTasks() {
155 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
156 openBulkSnoozeModal('tasks', selectedTaskIds, async (until) => {
157 const ids = Array.from(selectedTaskIds);
158 if (ids.length === 0) return;
159 GoingsOn.ui.closeModal();
160 GoingsOn.cache.invalidate('tasks');
161
162 GoingsOn.ui.bulkActionWithUndo({
163 ids,
164 label: 'Snoozed',
165 itemType: 'task',
166 apply: (ids) => {
167 const idSet = new Set(ids);
168 const cached = GoingsOn.state.tasks || [];
169 const removed = cached.filter(t => idSet.has(t.id));
170 GoingsOn.state.set('tasks', cached.filter(t => !idSet.has(t.id)));
171 selectedTaskIds.clear();
172 updateBulkActionsBar();
173 return removed;
174 },
175 revert: (removed) => {
176 const current = GoingsOn.state.tasks || [];
177 GoingsOn.state.set('tasks', [...current, ...removed]);
178 },
179 commit: async (ids) => {
180 const results = await Promise.allSettled(ids.map(id => GoingsOn.api.tasks.snooze(id, until)));
181 const failed = results.filter(r => r.status === 'rejected').length;
182 if (failed === ids.length) {
183 throw results.find(r => r.status === 'rejected').reason;
184 }
185 if (failed > 0) {
186 GoingsOn.ui.showToast(`${ids.length - failed} snoozed, ${failed} failed`, 'warning');
187 }
188 GoingsOn.tasks.load();
189 },
190 errorMessage: 'Failed to snooze tasks',
191 });
192 });
193 }
194
195 async function setProjectTasks() {
196 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
197 if (selectedTaskIds.size === 0) return;
198
199 const projects = GoingsOn.projects?.getCache?.() || [];
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" data-act="bulk._applyProject" data-args='[null]'>No Project</button>`;
202 for (const p of projects) {
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 }
205 GoingsOn.ui.openModal('Set Project', `<div class="bulk-modal-scroll">${optionsHtml}</div>`);
206 }
207
208 async function setPriorityTasks() {
209 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
210 if (selectedTaskIds.size === 0) return;
211
212 const content = `
213 <p class="bulk-modal-prompt bulk-modal-prompt--wide">Set priority for ${selectedTaskIds.size} tasks:</p>
214 <div class="bulk-priority-row">
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 </div>
219 `;
220 GoingsOn.ui.openModal('Set Priority', content);
221 }
222
223 function _applyProject(projectId) {
224 _bulkUpdateTaskField('projectId', projectId, 'project');
225 }
226
227 function _applyPriority(priority) {
228 _bulkUpdateTaskField('priority', priority, 'priority');
229 }
230
231 /**
232 * Shared shape for bulk-updating a single field on tasks.
233 * @param {string} field - cached-task field name ('projectId', 'priority', etc.)
234 * @param {*} newValue - new value to set on every selected task
235 * @param {string} labelNoun - human-readable field name for toasts
236 */
237 function _bulkUpdateTaskField(field, newValue, labelNoun) {
238 const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set();
239 const ids = Array.from(selectedTaskIds);
240 if (ids.length === 0) return;
241
242 GoingsOn.ui.closeModal();
243 GoingsOn.cache.invalidate('tasks');
244
245 GoingsOn.ui.bulkActionWithUndo({
246 ids,
247 label: `Updated ${labelNoun} on`,
248 itemType: 'task',
249 apply: (ids) => {
250 const idSet = new Set(ids);
251 const cached = GoingsOn.state.tasks || [];
252 const prev = new Map();
253 const next = cached.map(t => {
254 if (!idSet.has(t.id)) return t;
255 prev.set(t.id, t[field]);
256 return { ...t, [field]: newValue };
257 });
258 GoingsOn.state.set('tasks', next);
259 selectedTaskIds.clear();
260 updateBulkActionsBar();
261 return prev;
262 },
263 revert: (prev) => {
264 const cached = GoingsOn.state.tasks || [];
265 GoingsOn.state.set('tasks', cached.map(t =>
266 prev.has(t.id) ? { ...t, [field]: prev.get(t.id) } : t
267 ));
268 },
269 commit: async (ids) => {
270 // One batched, transactional command instead of the old 2N
271 // get+update round-trips (Perf S4). The optimistic apply() above
272 // already updated the visible rows in place.
273 if (field === 'projectId') {
274 await GoingsOn.api.tasks.bulkSetProject(ids, newValue);
275 // Project doesn't affect urgency/sort, so the in-place update stands.
276 } else if (field === 'priority') {
277 await GoingsOn.api.tasks.bulkSetPriority(ids, newValue);
278 // Priority feeds urgency, which is the sort key — one refetch
279 // re-sorts the list (one round-trip, not 2N).
280 GoingsOn.tasks.load();
281 }
282 },
283 errorMessage: `Failed to update ${labelNoun}`,
284 });
285 }
286
287 // ============ Email Bulk Actions ============
288
289 function archiveEmails() {
290 _bulkRemoveEmails('Archived', 'archive', id => GoingsOn.api.emails.archive(id));
291 }
292
293 function deleteEmails() {
294 _bulkRemoveEmails('Deleted', 'delete', id => GoingsOn.api.emails.delete(id));
295 }
296
297 function markEmailsRead() {
298 const selectedEmailIds = GoingsOn.emails?.getSelected?.() || new Set();
299 const ids = Array.from(selectedEmailIds);
300 if (ids.length === 0) return;
301 GoingsOn.cache.invalidate('emails');
302
303 GoingsOn.ui.bulkActionWithUndo({
304 ids,
305 label: 'Marked read',
306 itemType: 'email',
307 apply: (ids) => {
308 const idSet = new Set(ids);
309 const cached = GoingsOn.state.emails || [];
310 const prev = new Map();
311 const next = cached.map(e => {
312 if (!idSet.has(e.id)) return e;
313 prev.set(e.id, e.is_read);
314 return { ...e, is_read: true, hasUnread: false };
315 });
316 GoingsOn.state.set('emails', next);
317 selectedEmailIds.clear();
318 updateBulkActionsBar();
319 return prev;
320 },
321 revert: (prev) => {
322 const cached = GoingsOn.state.emails || [];
323 GoingsOn.state.set('emails', cached.map(e =>
324 prev.has(e.id) ? { ...e, is_read: prev.get(e.id), hasUnread: !prev.get(e.id) } : e
325 ));
326 },
327 commit: async (ids) => {
328 const results = await Promise.allSettled(ids.map(id => GoingsOn.api.emails.markRead(id)));
329 const failed = results.filter(r => r.status === 'rejected').length;
330 if (failed === ids.length) {
331 throw results.find(r => r.status === 'rejected').reason;
332 }
333 if (failed > 0) {
334 GoingsOn.ui.showToast(`${ids.length - failed} marked read, ${failed} failed`, 'warning');
335 }
336 GoingsOn.emails.load();
337 },
338 errorMessage: 'Failed to mark emails read',
339 });
340 }
341
342 function snoozeEmails() {
343 const selectedEmailIds = GoingsOn.emails?.getSelected?.() || new Set();
344 openBulkSnoozeModal('emails', selectedEmailIds, async (until) => {
345 const ids = Array.from(selectedEmailIds);
346 if (ids.length === 0) return;
347 GoingsOn.ui.closeModal();
348 _bulkRemoveEmailsByIds(ids, selectedEmailIds, 'Snoozed', 'snooze',
349 id => GoingsOn.api.emails.snooze(id, until));
350 });
351 }
352
353 /**
354 * Bulk operation that removes emails from the visible list (archive / delete / snooze).
355 */
356 function _bulkRemoveEmails(label, verb, apiFn) {
357 const selectedEmailIds = GoingsOn.emails?.getSelected?.() || new Set();
358 const ids = Array.from(selectedEmailIds);
359 if (ids.length === 0) return;
360 _bulkRemoveEmailsByIds(ids, selectedEmailIds, label, verb, apiFn);
361 }
362
363 function _bulkRemoveEmailsByIds(ids, selectedEmailIds, label, verb, apiFn) {
364 GoingsOn.cache.invalidate('emails');
365 GoingsOn.ui.bulkActionWithUndo({
366 ids,
367 label,
368 itemType: 'email',
369 apply: (ids) => {
370 const idSet = new Set(ids);
371 const cached = GoingsOn.state.emails || [];
372 const removed = cached.filter(e => idSet.has(e.id));
373 GoingsOn.state.set('emails', cached.filter(e => !idSet.has(e.id)));
374 selectedEmailIds.clear();
375 updateBulkActionsBar();
376 return removed;
377 },
378 revert: (removed) => {
379 const current = GoingsOn.state.emails || [];
380 GoingsOn.state.set('emails', [...current, ...removed]);
381 },
382 commit: async (ids) => {
383 const results = await Promise.allSettled(ids.map(id => apiFn(id)));
384 const failed = results.filter(r => r.status === 'rejected').length;
385 if (failed === ids.length) {
386 throw results.find(r => r.status === 'rejected').reason;
387 }
388 if (failed > 0) {
389 GoingsOn.ui.showToast(`${ids.length - failed} ${verb}d, ${failed} failed`, 'warning');
390 }
391 GoingsOn.emails.load();
392 },
393 errorMessage: `Failed to ${verb} emails`,
394 });
395 }
396
397 // ============ Select All ============
398
399 function selectAllTasks() {
400 GoingsOn.tasks?.selectAll?.();
401 }
402
403 function selectAllEmails() {
404 GoingsOn.emails?.selectAll?.();
405 }
406
407 // ============ Populate GoingsOn.bulk Namespace ============
408
409 GoingsOn.bulk = {
410 updateBar: updateBulkActionsBar,
411 // Tasks
412 completeTasks,
413 deleteTasks,
414 snoozeTasks,
415 setProjectTasks,
416 setPriorityTasks,
417 selectAllTasks,
418 // Emails
419 archiveEmails,
420 deleteEmails,
421 markEmailsRead,
422 snoozeEmails,
423 selectAllEmails,
424 // Internal
425 _snoozeCallback: null,
426 _applyProject,
427 _applyPriority,
428 };
429
430 })();
431