Skip to main content

max / goingson

28.9 KB · 729 lines History Blame Raw
1 /**
2 * GoingsOn - Tasks Module
3 * Task CRUD, subtasks, annotations, event handlers.
4 * Form definitions live in task-forms.js.
5 * Board/view mode live in task-board.js.
6 * Filter/sort/pagination/selection live in tasks-filter.js.
7 * Row rendering lives in tasks-render.js.
8 */
9
10 // ============ Tasks Module ============
11
12 (function() {
13 'use strict';
14 const esc = GoingsOn.utils.escapeHtml;
15 const escAttr = GoingsOn.utils.escapeAttr;
16
17 // ============ Task Selection & Pagination ============
18
19 const taskSelection = new GoingsOn.SelectionManager('task', '#task-list-container', 'task-bulk-actions');
20 const taskPagination = new GoingsOn.PaginationManager('task', GoingsOn.state.itemsPerPage);
21
22 // Virtual scroller instance
23 let taskScroller = null;
24
25 // ============ Delegated references ============
26
27 const getTaskFormFields = (...args) => GoingsOn.taskForms.getTaskFormFields(...args);
28 const setupMilestoneSelect = (...args) => GoingsOn.taskForms.setupMilestoneSelect(...args);
29 const renderTaskBadges = (...args) => GoingsOn.tasksRender.renderTaskBadges(...args);
30 const formatRecurrence = (...args) => GoingsOn.tasksRender.formatRecurrence(...args);
31 const renderTaskRow = (...args) => GoingsOn.tasksRender.renderTaskRow(...args);
32 const renderSubtasksModal = (...args) => GoingsOn.tasksRender.renderSubtasksModal(...args);
33
34 // ============ Core Functions ============
35
36 /**
37 * Load and render the task list for the current view mode (list or board).
38 */
39 async function load() {
40 if (GoingsOn.cache.isFresh('tasks')) return;
41
42 GoingsOn.tasksFilter.populateProjectFilter();
43 // Phase 7 Tier 4 — restore filter state from URL on every load. This
44 // makes reload, deep-link, and back-button preserve the user's view.
45 // populateProjectFilter must run first so the project <option> exists.
46 GoingsOn.tasksFilter.restoreFiltersFromUrl();
47
48 if (GoingsOn.taskBoard.getViewMode() === 'board') {
49 await GoingsOn.taskBoard.renderBoard();
50 } else {
51 await renderFilteredTasks();
52 }
53
54 GoingsOn.cache.markLoaded('tasks');
55 }
56
57 /**
58 * Fetch filtered/sorted tasks from backend and render via virtual scroller.
59 */
60 /**
61 * Update the "N tasks" count chip in the filter bar.
62 * Surfaces the 500-row cap when total > shown.
63 */
64 function _updateTaskCount(total, shown) {
65 const el = document.getElementById('task-count');
66 if (!el) return;
67 if (typeof total !== 'number' || total < 0) {
68 el.textContent = '';
69 el.classList.remove('filter-count--capped');
70 return;
71 }
72 const noun = total === 1 ? 'task' : 'tasks';
73 if (shown < total) {
74 el.textContent = `${shown} of ${total} ${noun} — narrow with filters`;
75 el.classList.add('filter-count--capped');
76 } else {
77 el.textContent = `${total} ${noun}`;
78 el.classList.remove('filter-count--capped');
79 }
80 }
81
82 async function renderFilteredTasks() {
83 const container = document.getElementById('task-list-container');
84 const uiFilters = GoingsOn.tasksFilter.getFilters();
85
86 // Build filter query for backend - fetch more items for virtual scrolling
87 const backendFilters = {
88 showSnoozed: uiFilters.showSnoozed,
89 waitingOnly: uiFilters.waitingOnly,
90 offset: 0,
91 limit: 500, // Fetch more for virtual scrolling
92 };
93
94 // Status: UI uses lowercase ('pending'), backend expects capitalized ('Pending')
95 if (uiFilters.status) {
96 backendFilters.status = uiFilters.status.charAt(0).toUpperCase() + uiFilters.status.slice(1);
97 }
98
99 // Project ID
100 if (uiFilters.projectId) {
101 backendFilters.projectId = uiFilters.projectId;
102 }
103
104 // Milestone ID
105 if (uiFilters.milestoneId) {
106 backendFilters.milestoneId = uiFilters.milestoneId;
107 }
108
109 // Priority: UI uses 'H', 'M', 'L', backend expects 'High', 'Medium', 'Low'
110 if (uiFilters.priority) {
111 const priorityMap = { H: 'High', M: 'Medium', L: 'Low' };
112 backendFilters.priority = priorityMap[uiFilters.priority] || uiFilters.priority;
113 }
114
115 // Add sorting parameters (backend handles sorting)
116 backendFilters.sortColumn = GoingsOn.tasksFilter.getSortColumn();
117 backendFilters.sortDirection = GoingsOn.tasksFilter.getSortDirection();
118
119 let response;
120 try {
121 // Fetch filtered and sorted tasks from backend
122 response = await GoingsOn.api.tasks.listFiltered(backendFilters);
123 // Update cache with filtered results
124 GoingsOn.state.set('tasks', response.tasks);
125 } catch (err) {
126 container.innerHTML = `<div class="loading loading--error">Failed to load tasks. <button class="btn-link" onclick="GoingsOn.tasks.renderFilteredTasks()">Try again</button></div>`;
127 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load tasks'), 'error', {
128 action: { label: 'Retry', fn: renderFilteredTasks },
129 duration: 8000,
130 });
131 return;
132 }
133
134 if (response.total === 0) {
135 _updateTaskCount(0, 0);
136 const isDefaultFilter = uiFilters.status === 'pending' && !uiFilters.projectId && !uiFilters.priority && !uiFilters.waitingOnly;
137 let emptyHtml;
138 if (isDefaultFilter) {
139 // Check if user has any tasks at all (completed or otherwise)
140 let hasAnyTasks = false;
141 try {
142 const allResp = await GoingsOn.api.tasks.listFiltered({ offset: 0, limit: 1 });
143 hasAnyTasks = allResp.total > 0;
144 } catch (_) { /* fall through */ }
145
146 if (hasAnyTasks) {
147 // All tasks done — celebration!
148 emptyHtml = GoingsOn.ui.renderEmptyState('All clear. No pending tasks.', null, null, 'tasks');
149 } else {
150 // Brand new user — onboarding
151 emptyHtml = GoingsOn.ui.renderEmptyState('No tasks yet.', 'New Task', 'GoingsOn.tasks.openNew()', 'tasks');
152 }
153 } else {
154 emptyHtml = GoingsOn.ui.renderEmptyState('No tasks match the current filters.', 'New Task', 'GoingsOn.tasks.openNew()');
155 }
156 container.innerHTML = emptyHtml;
157 // Hide pagination when using virtual scrolling
158 const paginationEl = document.getElementById('task-pagination');
159 if (paginationEl) paginationEl.classList.add('hidden');
160 // Destroy existing scroller
161 if (taskScroller) {
162 taskScroller.destroy();
163 taskScroller = null;
164 }
165 return;
166 }
167
168 // Phase 7 Tier 2 #9 — surface the count so users can see "247 tasks"
169 // and notice when the 500-row server cap is hit.
170 _updateTaskCount(response.total, response.tasks.length);
171
172 // Backend already returns tasks sorted by sortColumn/sortDirection
173 let displayTasks = response.tasks;
174 displayTasks = displayTasks.map(t => ({ ...t, displayDescription: t.description }));
175
176 // Update state with sorted tasks (already sorted by backend)
177 GoingsOn.state.set('tasks', displayTasks);
178
179 // Update selection manager with current items for data-based range selection
180 taskSelection.setItems(displayTasks);
181
182 // Update sort arrow UI
183 GoingsOn.tasksFilter.updateSortArrows();
184
185 // Hide pagination - virtual scrolling replaces it
186 const paginationEl = document.getElementById('task-pagination');
187 if (paginationEl) paginationEl.classList.add('hidden');
188
189 // Initialize or refresh virtual scroller
190 if (!taskScroller) {
191 taskScroller = new GoingsOn.VirtualScroller({
192 container: container,
193 renderItem: renderTaskRow,
194 getItems: () => GoingsOn.state.tasks || [],
195 rowHeight: { estimated: 52, measure: true },
196 overscan: 5,
197 });
198 } else {
199 taskScroller.refresh();
200 }
201 }
202
203 function openNew() {
204 GoingsOn.ui.openFormModal({
205 title: 'New Task',
206 entityType: 'task',
207 isEdit: false,
208 fields: getTaskFormFields(),
209 onSubmit: create,
210 });
211 setupMilestoneSelect('task', 'new');
212 GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence');
213 }
214
215 /**
216 * Open the new task form modal pre-filled for a specific project.
217 * @param {string} projectId - Project to pre-select in the form
218 */
219 function openNewForProject(projectId) {
220 GoingsOn.ui.openFormModal({
221 title: 'New Task',
222 entityType: 'task',
223 isEdit: false,
224 fields: getTaskFormFields(null, projectId),
225 onSubmit: create,
226 });
227 setupMilestoneSelect('task', 'new', projectId);
228 }
229
230 /**
231 * Create a new task from form data.
232 * @param {Object} data - Form data with description, project_id, priority, due, tags, etc.
233 */
234 async function create(data) {
235 const tagsValue = data.tags?.trim() || '';
236 const form = document.querySelector('.modal-content form');
237 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'task', data.recurrence) : null;
238 const input = {
239 description: data.description,
240 projectId: data.project_id || null,
241 priority: data.priority,
242 due: data.due ? new Date(data.due).toISOString() : null,
243 tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [],
244 recurrence: data.recurrence,
245 recurrenceRule,
246 contactId: data.contact_id || null,
247 milestoneId: data.milestone_id || null,
248 estimatedMinutes: data.estimated_minutes ? parseInt(data.estimated_minutes, 10) : null,
249 };
250
251 const reloadFns = [load];
252 const currentProjectId = GoingsOn.getCurrentProjectId();
253 if (currentProjectId) {
254 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
255 }
256
257 GoingsOn.cache.invalidate('tasks');
258 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.create(input), {
259 successMessage: 'Task created!',
260 errorMessage: 'Failed to create task',
261 reload: reloadFns,
262 });
263 }
264
265 /**
266 * Open the task actions modal with start, complete, edit, snooze, etc.
267 * @param {string} id - Task ID
268 */
269 async function openActions(id) {
270 const task = await GoingsOn.api.tasks.get(id);
271 const isSnoozed = task && task.isSnoozed;
272
273 const content = `
274 <div class="stack stack-2">
275 <button class="btn btn-secondary" onclick="GoingsOn.tasks.start('${escAttr(id)}')">Start Task</button>
276 <button class="btn btn-secondary" onclick="GoingsOn.tasks.complete('${escAttr(id)}')">Complete Task</button>
277 <button class="btn btn-secondary" onclick="GoingsOn.tasks.openEdit('${escAttr(id)}')">Edit Task</button>
278 <button class="btn btn-secondary" onclick="GoingsOn.tasks.openSubtasks('${escAttr(id)}')">Manage Subtasks</button>
279 <button class="btn btn-secondary" onclick="GoingsOn.tasks.addAnnotation('${escAttr(id)}')">Add Note</button>
280 <button class="btn btn-secondary" onclick="GoingsOn.attachments.openPanel('${escAttr(id)}', null)">Attachments</button>
281 <hr class="hr-soft">
282 ${isSnoozed
283 ? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('task', '${escAttr(id)}')">Unsnooze Task</button>`
284 : `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('task', '${escAttr(id)}')">Snooze Task</button>`
285 }
286 <button class="btn btn-secondary" onclick="GoingsOn.dayPlan.openScheduleTaskModal('${escAttr(id)}')">Schedule Time Block</button>
287 <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.timeTracking.startTimer('${escAttr(id)}')">Track Time</button>
288 <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.focusTimer.start('${escAttr(id)}')">Focus Mode (25/5)</button>
289 <hr class="hr-soft">
290 <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.tasks.delete('${escAttr(id)}')">Delete Task</button>
291 </div>
292 `;
293 GoingsOn.ui.openModal('Task Actions', content);
294 }
295
296 /**
297 * Fetch a task and open the edit form modal.
298 * @param {string} id - Task ID to edit
299 */
300 async function openEdit(id) {
301 try {
302 const task = await GoingsOn.api.tasks.get(id);
303 if (!task) {
304 GoingsOn.ui.showToast('Task not found', 'error');
305 return;
306 }
307
308 const extraContent = task.source_email_id ? `
309 <div class="form-group source-email-link">
310 <label class="form-label">Source Email</label>
311 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.emails.open('${escAttr(task.source_email_id)}')">
312 View Related Email
313 </button>
314 </div>
315 ` : '';
316
317 GoingsOn.ui.openFormModal({
318 title: 'Edit Task',
319 entityType: 'task',
320 isEdit: true,
321 entityId: id,
322 fields: getTaskFormFields(task),
323 onSubmit: (data) => update(id, data),
324 extraContent,
325 });
326 setupMilestoneSelect('task', 'edit', task.projectId, task.milestoneId);
327 GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence');
328 } catch (err) {
329 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
330 }
331 }
332
333 /**
334 * Update an existing task from form data.
335 * @param {string} id - Task ID to update
336 * @param {Object} data - Form data with description, project_id, status, priority, etc.
337 */
338 async function update(id, data) {
339 const tagsValue = data.tags?.trim() || '';
340 const form = document.querySelector('.modal-content form');
341 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'task', data.recurrence) : null;
342 const input = {
343 description: data.description,
344 projectId: data.project_id || null,
345 status: data.status,
346 priority: data.priority,
347 due: data.due ? new Date(data.due).toISOString() : null,
348 tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [],
349 recurrence: data.recurrence,
350 recurrenceRule,
351 contactId: data.contact_id || null,
352 milestoneId: data.milestone_id || null,
353 estimatedMinutes: data.estimated_minutes ? parseInt(data.estimated_minutes, 10) : null,
354 };
355
356 const reloadFns = [load];
357 const currentProjectId = GoingsOn.getCurrentProjectId();
358 if (currentProjectId) {
359 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
360 }
361
362 GoingsOn.cache.invalidate('tasks');
363 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.update(id, input), {
364 successMessage: 'Task updated!',
365 errorMessage: 'Failed to update task',
366 reload: reloadFns,
367 });
368 }
369
370 /**
371 * Fetch a task's subtasks and open the subtasks management modal.
372 * @param {string} taskId - Task ID to manage subtasks for
373 */
374 async function openSubtasks(taskId) {
375 try {
376 const task = await GoingsOn.api.tasks.get(taskId);
377 if (!task) {
378 GoingsOn.ui.showToast('Task not found', 'error');
379 return;
380 }
381
382 renderSubtasksModal(taskId, task.subtasks || []);
383 } catch (err) {
384 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
385 }
386 }
387
388 /**
389 * Open a picker modal to link another task as a subtask.
390 * @param {string} parentTaskId - Parent task to link a subtask to
391 */
392 async function openLinkTaskPicker(parentTaskId) {
393 // Get all pending/started tasks (excluding the parent task)
394 const filters = { status: 'Pending', limit: 100 };
395 const response = await GoingsOn.api.tasks.listFiltered(filters);
396 const availableTasks = response.tasks.filter(t => t.id !== parentTaskId);
397
398 if (availableTasks.length === 0) {
399 GoingsOn.ui.showToast('No other tasks available to link', 'info');
400 return;
401 }
402
403 const taskOptions = availableTasks.map(t => `
404 <div class="link-task-item" onclick="GoingsOn.tasks.linkTask('${escAttr(parentTaskId)}', '${escAttr(t.id)}')">
405 <span class="link-task-desc">${esc(t.description)}</span>
406 ${t.projectName ? `<span class="link-task-project">${esc(t.projectName)}</span>` : ''}
407 </div>
408 `).join('');
409
410 const content = `
411 <p class="link-task-prompt">Select a task to link as a subtask. The linked task's completion will sync with this subtask.</p>
412 <div class="link-task-list">
413 ${taskOptions}
414 </div>
415 <div class="form-actions form-actions--top-spaced">
416 <button type="button" class="btn btn-secondary" onclick="GoingsOn.tasks.openSubtasks('${escAttr(parentTaskId)}')">Cancel</button>
417 </div>
418 `;
419 GoingsOn.ui.openModal('Link Task', content);
420 }
421
422 /**
423 * Link an existing task as a subtask of the parent task.
424 * @param {string} parentTaskId - Parent task ID
425 * @param {string} linkedTaskId - Task ID to link as subtask
426 */
427 async function linkTask(parentTaskId, linkedTaskId) {
428 try {
429 await GoingsOn.api.subtasks.addLink(parentTaskId, linkedTaskId);
430 GoingsOn.ui.showToast('Task linked!', 'success');
431 // Reload subtasks modal
432 const task = await GoingsOn.api.tasks.get(parentTaskId);
433 renderSubtasksModal(parentTaskId, task.subtasks || []);
434 } catch (err) {
435 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to link task'), 'error');
436 }
437 }
438
439 function closeSubtasksAndRefresh() {
440 GoingsOn.ui.closeModal();
441 // Refresh task list if we're on the tasks view
442 const currentView = GoingsOn.getCurrentView();
443 if (currentView === 'tasks') {
444 load();
445 }
446 // Refresh project dashboard if we're viewing one
447 const currentProjectId = GoingsOn.getCurrentProjectId();
448 if (currentProjectId) {
449 GoingsOn.projects.loadDashboard(currentProjectId);
450 }
451 }
452
453 async function addSubtask(e, taskId) {
454 e.preventDefault();
455 const form = e.target;
456 const text = form.subtask_text.value.trim();
457 if (!text) return;
458
459 try {
460 await GoingsOn.api.subtasks.add(taskId, text);
461 form.subtask_text.value = '';
462 // Reload the subtasks modal
463 const task = await GoingsOn.api.tasks.get(taskId);
464 renderSubtasksModal(taskId, task.subtasks || []);
465 } catch (err) {
466 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to add subtask'), 'error');
467 }
468 }
469
470 async function toggleSubtask(taskId, subtaskId) {
471 try {
472 await GoingsOn.api.subtasks.toggle(taskId, subtaskId);
473 // Reload the subtasks modal
474 const task = await GoingsOn.api.tasks.get(taskId);
475 renderSubtasksModal(taskId, task.subtasks || []);
476 } catch (err) {
477 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to toggle subtask'), 'error');
478 }
479 }
480
481 async function deleteSubtask(taskId, subtaskId) {
482 const confirmed = await GoingsOn.ui.confirmDelete('subtask');
483 if (!confirmed) return;
484
485 try {
486 await GoingsOn.api.subtasks.delete(taskId, subtaskId);
487 // Reload the subtasks modal
488 const task = await GoingsOn.api.tasks.get(taskId);
489 renderSubtasksModal(taskId, task.subtasks || []);
490 } catch (err) {
491 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to delete subtask'), 'error');
492 }
493 }
494
495 /**
496 * Open a form modal to add an annotation note to a task.
497 * @param {string} taskId - Task ID to annotate
498 */
499 function addAnnotation(taskId) {
500 GoingsOn.ui.openFormModal({
501 title: 'Add Annotation',
502 entityType: 'annotation',
503 isEdit: false,
504 fields: [
505 {
506 name: 'note',
507 type: 'textarea',
508 label: 'Note',
509 placeholder: 'Enter annotation note...',
510 required: true,
511 validate: (v) => v && v.length > 2000 ? 'Maximum 2000 characters' : null,
512 },
513 ],
514 onSubmit: async (data) => {
515 await GoingsOn.ui.apiCall(GoingsOn.api.annotations.add(taskId, data.note.trim()), {
516 successMessage: 'Annotation added!',
517 errorMessage: 'Failed to add annotation',
518 });
519 },
520 });
521 }
522
523 /**
524 * Open a modal to assign or change a task's milestone.
525 * @param {string} taskId - Task ID to set milestone on
526 */
527 async function openSetMilestone(taskId) {
528 try {
529 const task = await GoingsOn.api.tasks.get(taskId);
530 if (!task) {
531 GoingsOn.ui.showToast('Task not found', 'error');
532 return;
533 }
534
535 if (!task.projectId) {
536 GoingsOn.ui.showToast('Task must be in a project first', 'error');
537 return;
538 }
539
540 const milestones = await GoingsOn.api.milestones.list(task.projectId);
541 if (milestones.length === 0) {
542 GoingsOn.ui.showToast('No milestones in this project', 'info');
543 return;
544 }
545
546 const milestoneOptions = [
547 { value: '', label: 'None' },
548 ...milestones.map(m => ({
549 value: m.id,
550 label: m.name,
551 selected: m.id === task.milestoneId,
552 })),
553 ];
554
555 GoingsOn.ui.openFormModal({
556 title: 'Set Milestone',
557 entityType: 'set-milestone',
558 isEdit: false,
559 fields: [
560 { name: 'milestone_id', type: 'select', label: 'Milestone', options: milestoneOptions, value: task.milestoneId || '' },
561 ],
562 onSubmit: async (data) => {
563 const input = {
564 description: task.description,
565 projectId: task.projectId,
566 status: task.status,
567 priority: task.priority,
568 due: task.due,
569 tags: task.tags,
570 recurrence: task.recurrence,
571 contactId: task.contactId || null,
572 milestoneId: data.milestone_id || null,
573 };
574
575 const reloadFns = [load];
576 const currentProjectId = GoingsOn.getCurrentProjectId();
577 if (currentProjectId) {
578 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
579 }
580
581 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.update(taskId, input), {
582 successMessage: 'Milestone updated!',
583 errorMessage: 'Failed to set milestone',
584 reload: reloadFns,
585 });
586 },
587 });
588 } catch (err) {
589 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
590 }
591 }
592
593 /**
594 * Transition a task from Pending to Started status.
595 * @param {string} id - Task ID to start
596 */
597 async function start(id) {
598 GoingsOn.cache.invalidate('tasks');
599 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.start(id), {
600 successMessage: 'Task started!',
601 errorMessage: 'Failed to start task',
602 reload: load,
603 });
604 }
605
606 /**
607 * Mark a task as completed. Spawns next instance for recurring tasks.
608 * @param {string} id - Task ID to complete
609 */
610 async function complete(id) {
611 GoingsOn.cache.invalidate('tasks');
612
613 // Animate row out before removing from state
614 const row = document.querySelector(`.task-row[data-id="${id}"]`);
615 if (row) row.classList.add('task-row-removing');
616
617 const cachedTasks = GoingsOn.state.tasks;
618 const removedTask = cachedTasks.find(t => t.id === id);
619
620 // Delay state update to let animation play
621 setTimeout(() => {
622 GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
623 }, 250);
624
625 GoingsOn.ui.showUndoToast('Task completed', {
626 onConfirm: async () => {
627 try {
628 await GoingsOn.api.tasks.complete(id);
629 load();
630 } catch (err) {
631 GoingsOn.ui.showToast('Failed to complete task', 'error');
632 load();
633 }
634 },
635 onUndo: () => {
636 if (removedTask) {
637 GoingsOn.state.set('tasks', [...GoingsOn.state.tasks, removedTask]);
638 }
639 },
640 });
641 }
642
643 /**
644 * Delete a task with confirmation and undo support.
645 * Optimistically removes from UI; actual deletion happens after undo window.
646 * @param {string} id - Task ID to delete
647 */
648 async function deleteTask(id) {
649 if (!await GoingsOn.ui.confirmDelete('task')) return;
650
651 GoingsOn.cache.invalidate('tasks');
652 // Hide task from UI immediately
653 const cachedTasks = GoingsOn.state.tasks;
654 const removedTask = cachedTasks.find(t => t.id === id);
655 GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
656
657 GoingsOn.ui.showUndoToast('Task deleted', {
658 onConfirm: async () => {
659 // Actually delete after undo window expires
660 try {
661 await GoingsOn.api.tasks.delete(id);
662 } catch (err) {
663 GoingsOn.ui.showToast('Failed to delete task', 'error');
664 load();
665 }
666 },
667 onUndo: () => {
668 // Restore task in UI
669 if (removedTask) {
670 GoingsOn.state.set('tasks', [...GoingsOn.state.tasks, removedTask]);
671 }
672 },
673 });
674 }
675
676 // ============ Populate GoingsOn.tasks Namespace ============
677
678 GoingsOn.tasks = {
679 load,
680 renderFilteredTasks,
681 setViewMode: (...a) => GoingsOn.taskBoard.setViewMode(...a),
682 renderBoard: (...a) => GoingsOn.taskBoard.renderBoard(...a),
683 openNew,
684 openNewForProject,
685 create,
686 openActions,
687 openEdit,
688 update,
689 openSubtasks,
690 renderSubtasksModal,
691 closeSubtasksAndRefresh,
692 addSubtask,
693 toggleSubtask,
694 deleteSubtask,
695 addAnnotation,
696 openSetMilestone,
697 openLinkTaskPicker,
698 linkTask,
699 start,
700 complete,
701 delete: deleteTask,
702 // Delegated to tasksFilter
703 populateProjectFilter: (...a) => GoingsOn.tasksFilter.populateProjectFilter(...a),
704 populateMilestoneFilter: (...a) => GoingsOn.tasksFilter.populateMilestoneFilter(...a),
705 getFilters: (...a) => GoingsOn.tasksFilter.getFilters(...a),
706 applyFilters: (...a) => GoingsOn.tasksFilter.applyFilters(...a),
707 clearFilters: (...a) => GoingsOn.tasksFilter.clearFilters(...a),
708 goToPage: (...a) => GoingsOn.tasksFilter.goToPage(...a),
709 toggleSelection: (...a) => GoingsOn.tasksFilter.toggleSelection(...a),
710 selectAll: (...a) => GoingsOn.tasksFilter.selectAll(...a),
711 getSelected: (...a) => GoingsOn.tasksFilter.getSelected(...a),
712 clearSelected: (...a) => GoingsOn.tasksFilter.clearSelected(...a),
713 sort: (...a) => GoingsOn.tasksFilter.sort(...a),
714 toggleMobileFilters: (...a) => GoingsOn.tasksFilter.toggleMobileFilters(...a),
715 mobileSortChange: (...a) => GoingsOn.tasksFilter.mobileSortChange(...a),
716 // Helpers
717 renderTaskBadges,
718 renderTaskRow,
719 formatRecurrence,
720 getFormFields: getTaskFormFields,
721 // Expose managers
722 selection: taskSelection,
723 pagination: taskPagination,
724 // Virtual scroller getter
725 getScroller: () => taskScroller,
726 };
727
728 })();
729