/** * GoingsOn - Bulk Actions Module * Multi-select and bulk operations for tasks & emails */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; /** * Show or hide the bulk actions bars based on current selection state. */ function updateBulkActionsBar() { const taskBar = document.getElementById('task-bulk-actions'); const emailBar = document.getElementById('email-bulk-actions'); const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); const selectedEmailIds = GoingsOn.emails?.getSelected?.() || new Set(); if (taskBar) { if (selectedTaskIds.size > 0) { taskBar.classList.remove('hidden'); document.getElementById('task-bulk-count').textContent = `${selectedTaskIds.size} selected`; } else { taskBar.classList.add('hidden'); } } if (emailBar) { if (selectedEmailIds.size > 0) { emailBar.classList.remove('hidden'); document.getElementById('email-bulk-count').textContent = `${selectedEmailIds.size} selected`; } else { emailBar.classList.add('hidden'); } } } // ============ Bulk Snooze Modal ============ /** * Open the snooze modal for a set of selected items. * @param {string} itemType - 'tasks' or 'emails' * @param {Set} selectedIds - IDs of items to snooze * @param {Function} snoozeCallback - (until: string) => Promise called with the chosen snooze time */ async function openBulkSnoozeModal(itemType, selectedIds, snoozeCallback) { if (selectedIds.size === 0) return; // Get pre-computed snooze options from backend const response = await GoingsOn.api.snooze.getOptions(); const options = response.options; let optionsHtml = `

Snooze ${selectedIds.size} ${itemType}:

`; for (const opt of options) { optionsHtml += ` `; } const content = `
${optionsHtml}
`; // Store the callback for when user clicks an option GoingsOn.bulk._snoozeCallback = snoozeCallback; GoingsOn.ui.openModal(`Bulk Snooze ${itemType.charAt(0).toUpperCase() + itemType.slice(1)}`, content); } // ============ Task Bulk Actions ============ async function completeTasks() { const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); if (selectedTaskIds.size === 0) return; const ids = Array.from(selectedTaskIds); GoingsOn.cache.invalidate('tasks'); GoingsOn.ui.bulkActionWithUndo({ ids, label: 'Completed', itemType: 'task', apply: (ids) => { const idSet = new Set(ids); const cached = GoingsOn.state.tasks || []; const removed = cached.filter(t => idSet.has(t.id)); GoingsOn.state.set('tasks', cached.filter(t => !idSet.has(t.id))); selectedTaskIds.clear(); updateBulkActionsBar(); return removed; }, revert: (removed) => { const current = GoingsOn.state.tasks || []; GoingsOn.state.set('tasks', [...current, ...removed]); }, commit: async (ids) => { const results = await Promise.allSettled(ids.map(id => GoingsOn.api.tasks.complete(id))); const failed = results.filter(r => r.status === 'rejected').length; if (failed > 0 && failed < ids.length) { GoingsOn.ui.showToast(`${ids.length - failed} succeeded, ${failed} failed`, 'warning'); } else if (failed === ids.length) { const firstErr = results.find(r => r.status === 'rejected'); throw firstErr.reason; } GoingsOn.tasks.load(); }, errorMessage: 'Failed to complete tasks', }); } function deleteTasks() { const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); if (selectedTaskIds.size === 0) return; const ids = Array.from(selectedTaskIds); GoingsOn.cache.invalidate('tasks'); GoingsOn.ui.bulkActionWithUndo({ ids, label: 'Deleted', itemType: 'task', apply: (ids) => { const idSet = new Set(ids); const cached = GoingsOn.state.tasks || []; const removed = cached.filter(t => idSet.has(t.id)); GoingsOn.state.set('tasks', cached.filter(t => !idSet.has(t.id))); selectedTaskIds.clear(); updateBulkActionsBar(); return removed; }, revert: (removed) => { const current = GoingsOn.state.tasks || []; GoingsOn.state.set('tasks', [...current, ...removed]); }, commit: async (ids) => { const results = await Promise.allSettled(ids.map(id => GoingsOn.api.tasks.delete(id))); const failed = results.filter(r => r.status === 'rejected').length; if (failed === ids.length) { throw results.find(r => r.status === 'rejected').reason; } if (failed > 0) { GoingsOn.ui.showToast(`${ids.length - failed} deleted, ${failed} failed`, 'warning'); } GoingsOn.tasks.load(); }, errorMessage: 'Failed to delete tasks', }); } function snoozeTasks() { const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); openBulkSnoozeModal('tasks', selectedTaskIds, async (until) => { const ids = Array.from(selectedTaskIds); if (ids.length === 0) return; GoingsOn.ui.closeModal(); GoingsOn.cache.invalidate('tasks'); GoingsOn.ui.bulkActionWithUndo({ ids, label: 'Snoozed', itemType: 'task', apply: (ids) => { const idSet = new Set(ids); const cached = GoingsOn.state.tasks || []; const removed = cached.filter(t => idSet.has(t.id)); GoingsOn.state.set('tasks', cached.filter(t => !idSet.has(t.id))); selectedTaskIds.clear(); updateBulkActionsBar(); return removed; }, revert: (removed) => { const current = GoingsOn.state.tasks || []; GoingsOn.state.set('tasks', [...current, ...removed]); }, commit: async (ids) => { const results = await Promise.allSettled(ids.map(id => GoingsOn.api.tasks.snooze(id, until))); const failed = results.filter(r => r.status === 'rejected').length; if (failed === ids.length) { throw results.find(r => r.status === 'rejected').reason; } if (failed > 0) { GoingsOn.ui.showToast(`${ids.length - failed} snoozed, ${failed} failed`, 'warning'); } GoingsOn.tasks.load(); }, errorMessage: 'Failed to snooze tasks', }); }); } async function setProjectTasks() { const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); if (selectedTaskIds.size === 0) return; const projects = GoingsOn.projects?.getCache?.() || []; let optionsHtml = `

Set project for ${selectedTaskIds.size} tasks:

`; optionsHtml += ``; for (const p of projects) { optionsHtml += ``; } GoingsOn.ui.openModal('Set Project', `
${optionsHtml}
`); } async function setPriorityTasks() { const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); if (selectedTaskIds.size === 0) return; const content = `

Set priority for ${selectedTaskIds.size} tasks:

`; GoingsOn.ui.openModal('Set Priority', content); } function _applyProject(projectId) { _bulkUpdateTaskField('projectId', projectId, 'project'); } function _applyPriority(priority) { _bulkUpdateTaskField('priority', priority, 'priority'); } /** * Shared shape for bulk-updating a single field on tasks. * @param {string} field - cached-task field name ('projectId', 'priority', etc.) * @param {*} newValue - new value to set on every selected task * @param {string} labelNoun - human-readable field name for toasts */ function _bulkUpdateTaskField(field, newValue, labelNoun) { const selectedTaskIds = GoingsOn.tasks?.getSelected?.() || new Set(); const ids = Array.from(selectedTaskIds); if (ids.length === 0) return; GoingsOn.ui.closeModal(); GoingsOn.cache.invalidate('tasks'); GoingsOn.ui.bulkActionWithUndo({ ids, label: `Updated ${labelNoun} on`, itemType: 'task', apply: (ids) => { const idSet = new Set(ids); const cached = GoingsOn.state.tasks || []; const prev = new Map(); const next = cached.map(t => { if (!idSet.has(t.id)) return t; prev.set(t.id, t[field]); return { ...t, [field]: newValue }; }); GoingsOn.state.set('tasks', next); selectedTaskIds.clear(); updateBulkActionsBar(); return prev; }, revert: (prev) => { const cached = GoingsOn.state.tasks || []; GoingsOn.state.set('tasks', cached.map(t => prev.has(t.id) ? { ...t, [field]: prev.get(t.id) } : t )); }, commit: async (ids) => { const results = await Promise.allSettled(ids.map(async (id) => { const task = await GoingsOn.api.tasks.get(id); if (!task) return; return GoingsOn.api.tasks.update(id, { description: task.description, priority: field === 'priority' ? newValue : task.priority, status: task.status, projectId: field === 'projectId' ? newValue : task.projectId, due: task.due, tags: task.tags, recurrence: task.recurrence, estimatedMinutes: task.estimatedMinutes, contactId: task.contactId, milestoneId: task.milestoneId, }); })); const failed = results.filter(r => r.status === 'rejected').length; if (failed === ids.length) { throw results.find(r => r.status === 'rejected').reason; } if (failed > 0) { GoingsOn.ui.showToast(`${ids.length - failed} updated, ${failed} failed`, 'warning'); } GoingsOn.tasks.load(); }, errorMessage: `Failed to update ${labelNoun}`, }); } // ============ Email Bulk Actions ============ function archiveEmails() { _bulkRemoveEmails('Archived', 'archive', id => GoingsOn.api.emails.archive(id)); } function deleteEmails() { _bulkRemoveEmails('Deleted', 'delete', id => GoingsOn.api.emails.delete(id)); } function markEmailsRead() { const selectedEmailIds = GoingsOn.emails?.getSelected?.() || new Set(); const ids = Array.from(selectedEmailIds); if (ids.length === 0) return; GoingsOn.cache.invalidate('emails'); GoingsOn.ui.bulkActionWithUndo({ ids, label: 'Marked read', itemType: 'email', apply: (ids) => { const idSet = new Set(ids); const cached = GoingsOn.state.emails || []; const prev = new Map(); const next = cached.map(e => { if (!idSet.has(e.id)) return e; prev.set(e.id, e.is_read); return { ...e, is_read: true, hasUnread: false }; }); GoingsOn.state.set('emails', next); selectedEmailIds.clear(); updateBulkActionsBar(); return prev; }, revert: (prev) => { const cached = GoingsOn.state.emails || []; GoingsOn.state.set('emails', cached.map(e => prev.has(e.id) ? { ...e, is_read: prev.get(e.id), hasUnread: !prev.get(e.id) } : e )); }, commit: async (ids) => { const results = await Promise.allSettled(ids.map(id => GoingsOn.api.emails.markRead(id))); const failed = results.filter(r => r.status === 'rejected').length; if (failed === ids.length) { throw results.find(r => r.status === 'rejected').reason; } if (failed > 0) { GoingsOn.ui.showToast(`${ids.length - failed} marked read, ${failed} failed`, 'warning'); } GoingsOn.emails.load(); }, errorMessage: 'Failed to mark emails read', }); } function snoozeEmails() { const selectedEmailIds = GoingsOn.emails?.getSelected?.() || new Set(); openBulkSnoozeModal('emails', selectedEmailIds, async (until) => { const ids = Array.from(selectedEmailIds); if (ids.length === 0) return; GoingsOn.ui.closeModal(); _bulkRemoveEmailsByIds(ids, selectedEmailIds, 'Snoozed', 'snooze', id => GoingsOn.api.emails.snooze(id, until)); }); } /** * Bulk operation that removes emails from the visible list (archive / delete / snooze). */ function _bulkRemoveEmails(label, verb, apiFn) { const selectedEmailIds = GoingsOn.emails?.getSelected?.() || new Set(); const ids = Array.from(selectedEmailIds); if (ids.length === 0) return; _bulkRemoveEmailsByIds(ids, selectedEmailIds, label, verb, apiFn); } function _bulkRemoveEmailsByIds(ids, selectedEmailIds, label, verb, apiFn) { GoingsOn.cache.invalidate('emails'); GoingsOn.ui.bulkActionWithUndo({ ids, label, itemType: 'email', apply: (ids) => { const idSet = new Set(ids); const cached = GoingsOn.state.emails || []; const removed = cached.filter(e => idSet.has(e.id)); GoingsOn.state.set('emails', cached.filter(e => !idSet.has(e.id))); selectedEmailIds.clear(); updateBulkActionsBar(); return removed; }, revert: (removed) => { const current = GoingsOn.state.emails || []; GoingsOn.state.set('emails', [...current, ...removed]); }, commit: async (ids) => { const results = await Promise.allSettled(ids.map(id => apiFn(id))); const failed = results.filter(r => r.status === 'rejected').length; if (failed === ids.length) { throw results.find(r => r.status === 'rejected').reason; } if (failed > 0) { GoingsOn.ui.showToast(`${ids.length - failed} ${verb}d, ${failed} failed`, 'warning'); } GoingsOn.emails.load(); }, errorMessage: `Failed to ${verb} emails`, }); } // ============ Select All ============ function selectAllTasks() { GoingsOn.tasks?.selectAll?.(); } function selectAllEmails() { GoingsOn.emails?.selectAll?.(); } // ============ Populate GoingsOn.bulk Namespace ============ GoingsOn.bulk = { updateBar: updateBulkActionsBar, // Tasks completeTasks, deleteTasks, snoozeTasks, setProjectTasks, setPriorityTasks, selectAllTasks, // Emails archiveEmails, deleteEmails, markEmailsRead, snoozeEmails, selectAllEmails, // Internal _snoozeCallback: null, _applyProject, _applyPriority, }; })();