Skip to main content

max / goingson

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