Skip to main content

max / goingson

29.2 KB · 737 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</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 = `<div class="empty-state">
149 <p class="empty-state-text">All clear. No pending tasks.</p>
150 </div>`;
151 } else {
152 // Brand new user — onboarding
153 emptyHtml = `<div class="empty-state">
154 <p class="empty-state-text">No tasks yet.</p>
155 <button class="btn btn-primary empty-state-action" onclick="GoingsOn.tasks.openNew()">New Task</button>
156 </div>`;
157 }
158 } else {
159 emptyHtml = `<div class="empty-state">
160 <p class="empty-state-text">No tasks match the current filters.</p>
161 <button class="btn btn-primary empty-state-action" onclick="GoingsOn.tasks.openNew()">New Task</button>
162 </div>`;
163 }
164 container.innerHTML = emptyHtml;
165 // Hide pagination when using virtual scrolling
166 const paginationEl = document.getElementById('task-pagination');
167 if (paginationEl) paginationEl.classList.add('hidden');
168 // Destroy existing scroller
169 if (taskScroller) {
170 taskScroller.destroy();
171 taskScroller = null;
172 }
173 return;
174 }
175
176 // Phase 7 Tier 2 #9 — surface the count so users can see "247 tasks"
177 // and notice when the 500-row server cap is hit.
178 _updateTaskCount(response.total, response.tasks.length);
179
180 // Backend already returns tasks sorted by sortColumn/sortDirection
181 let displayTasks = response.tasks;
182 displayTasks = displayTasks.map(t => ({ ...t, displayDescription: t.description }));
183
184 // Update state with sorted tasks (already sorted by backend)
185 GoingsOn.state.set('tasks', displayTasks);
186
187 // Update selection manager with current items for data-based range selection
188 taskSelection.setItems(displayTasks);
189
190 // Update sort arrow UI
191 GoingsOn.tasksFilter.updateSortArrows();
192
193 // Hide pagination - virtual scrolling replaces it
194 const paginationEl = document.getElementById('task-pagination');
195 if (paginationEl) paginationEl.classList.add('hidden');
196
197 // Initialize or refresh virtual scroller
198 if (!taskScroller) {
199 taskScroller = new GoingsOn.VirtualScroller({
200 container: container,
201 renderItem: renderTaskRow,
202 getItems: () => GoingsOn.state.tasks || [],
203 rowHeight: { estimated: 52, measure: true },
204 overscan: 5,
205 });
206 } else {
207 taskScroller.refresh();
208 }
209 }
210
211 function openNew() {
212 GoingsOn.ui.openFormModal({
213 title: 'New Task',
214 entityType: 'task',
215 isEdit: false,
216 fields: getTaskFormFields(),
217 onSubmit: create,
218 });
219 setupMilestoneSelect('task', 'new');
220 GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence');
221 }
222
223 /**
224 * Open the new task form modal pre-filled for a specific project.
225 * @param {string} projectId - Project to pre-select in the form
226 */
227 function openNewForProject(projectId) {
228 GoingsOn.ui.openFormModal({
229 title: 'New Task',
230 entityType: 'task',
231 isEdit: false,
232 fields: getTaskFormFields(null, projectId),
233 onSubmit: create,
234 });
235 setupMilestoneSelect('task', 'new', projectId);
236 }
237
238 /**
239 * Create a new task from form data.
240 * @param {Object} data - Form data with description, project_id, priority, due, tags, etc.
241 */
242 async function create(data) {
243 const tagsValue = data.tags?.trim() || '';
244 const form = document.querySelector('.modal-content form');
245 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'task', data.recurrence) : null;
246 const input = {
247 description: data.description,
248 projectId: data.project_id || null,
249 priority: data.priority,
250 due: data.due ? new Date(data.due).toISOString() : null,
251 tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [],
252 recurrence: data.recurrence,
253 recurrenceRule,
254 contactId: data.contact_id || null,
255 milestoneId: data.milestone_id || null,
256 estimatedMinutes: data.estimated_minutes ? parseInt(data.estimated_minutes, 10) : null,
257 };
258
259 const reloadFns = [load];
260 const currentProjectId = GoingsOn.getCurrentProjectId();
261 if (currentProjectId) {
262 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
263 }
264
265 GoingsOn.cache.invalidate('tasks');
266 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.create(input), {
267 successMessage: 'Task created!',
268 errorMessage: 'Failed to create task',
269 reload: reloadFns,
270 });
271 }
272
273 /**
274 * Open the task actions modal with start, complete, edit, snooze, etc.
275 * @param {string} id - Task ID
276 */
277 async function openActions(id) {
278 const task = await GoingsOn.api.tasks.get(id);
279 const isSnoozed = task && task.isSnoozed;
280
281 const content = `
282 <div class="stack stack-2">
283 <button class="btn btn-secondary" onclick="GoingsOn.tasks.start('${escAttr(id)}')">Start Task</button>
284 <button class="btn btn-secondary" onclick="GoingsOn.tasks.complete('${escAttr(id)}')">Complete Task</button>
285 <button class="btn btn-secondary" onclick="GoingsOn.tasks.openEdit('${escAttr(id)}')">Edit Task</button>
286 <button class="btn btn-secondary" onclick="GoingsOn.tasks.openSubtasks('${escAttr(id)}')">Manage Subtasks</button>
287 <button class="btn btn-secondary" onclick="GoingsOn.tasks.addAnnotation('${escAttr(id)}')">Add Note</button>
288 <button class="btn btn-secondary" onclick="GoingsOn.attachments.openPanel('${escAttr(id)}', null)">Attachments</button>
289 <hr class="hr-soft">
290 ${isSnoozed
291 ? `<button class="btn btn-secondary" onclick="GoingsOn.snooze.unsnooze('task', '${escAttr(id)}')">Unsnooze Task</button>`
292 : `<button class="btn btn-secondary" onclick="GoingsOn.snooze.openModal('task', '${escAttr(id)}')">Snooze Task</button>`
293 }
294 <button class="btn btn-secondary" onclick="GoingsOn.dayPlan.openScheduleTaskModal('${escAttr(id)}')">Schedule Time Block</button>
295 <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.timeTracking.startTimer('${escAttr(id)}')">Track Time</button>
296 <button class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.focusTimer.start('${escAttr(id)}')">Focus Mode (25/5)</button>
297 <hr class="hr-soft">
298 <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.tasks.delete('${escAttr(id)}')">Delete Task</button>
299 </div>
300 `;
301 GoingsOn.ui.openModal('Task Actions', content);
302 }
303
304 /**
305 * Fetch a task and open the edit form modal.
306 * @param {string} id - Task ID to edit
307 */
308 async function openEdit(id) {
309 try {
310 const task = await GoingsOn.api.tasks.get(id);
311 if (!task) {
312 GoingsOn.ui.showToast('Task not found', 'error');
313 return;
314 }
315
316 const extraContent = task.source_email_id ? `
317 <div class="form-group source-email-link">
318 <label class="form-label">Source Email</label>
319 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal(); GoingsOn.emails.open('${escAttr(task.source_email_id)}')">
320 View Related Email
321 </button>
322 </div>
323 ` : '';
324
325 GoingsOn.ui.openFormModal({
326 title: 'Edit Task',
327 entityType: 'task',
328 isEdit: true,
329 entityId: id,
330 fields: getTaskFormFields(task),
331 onSubmit: (data) => update(id, data),
332 extraContent,
333 });
334 setupMilestoneSelect('task', 'edit', task.projectId, task.milestoneId);
335 GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence');
336 } catch (err) {
337 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
338 }
339 }
340
341 /**
342 * Update an existing task from form data.
343 * @param {string} id - Task ID to update
344 * @param {Object} data - Form data with description, project_id, status, priority, etc.
345 */
346 async function update(id, data) {
347 const tagsValue = data.tags?.trim() || '';
348 const form = document.querySelector('.modal-content form');
349 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'task', data.recurrence) : null;
350 const input = {
351 description: data.description,
352 projectId: data.project_id || null,
353 status: data.status,
354 priority: data.priority,
355 due: data.due ? new Date(data.due).toISOString() : null,
356 tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [],
357 recurrence: data.recurrence,
358 recurrenceRule,
359 contactId: data.contact_id || null,
360 milestoneId: data.milestone_id || null,
361 estimatedMinutes: data.estimated_minutes ? parseInt(data.estimated_minutes, 10) : null,
362 };
363
364 const reloadFns = [load];
365 const currentProjectId = GoingsOn.getCurrentProjectId();
366 if (currentProjectId) {
367 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
368 }
369
370 GoingsOn.cache.invalidate('tasks');
371 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.update(id, input), {
372 successMessage: 'Task updated!',
373 errorMessage: 'Failed to update task',
374 reload: reloadFns,
375 });
376 }
377
378 /**
379 * Fetch a task's subtasks and open the subtasks management modal.
380 * @param {string} taskId - Task ID to manage subtasks for
381 */
382 async function openSubtasks(taskId) {
383 try {
384 const task = await GoingsOn.api.tasks.get(taskId);
385 if (!task) {
386 GoingsOn.ui.showToast('Task not found', 'error');
387 return;
388 }
389
390 renderSubtasksModal(taskId, task.subtasks || []);
391 } catch (err) {
392 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
393 }
394 }
395
396 /**
397 * Open a picker modal to link another task as a subtask.
398 * @param {string} parentTaskId - Parent task to link a subtask to
399 */
400 async function openLinkTaskPicker(parentTaskId) {
401 // Get all pending/started tasks (excluding the parent task)
402 const filters = { status: 'Pending', limit: 100 };
403 const response = await GoingsOn.api.tasks.listFiltered(filters);
404 const availableTasks = response.tasks.filter(t => t.id !== parentTaskId);
405
406 if (availableTasks.length === 0) {
407 GoingsOn.ui.showToast('No other tasks available to link', 'info');
408 return;
409 }
410
411 const taskOptions = availableTasks.map(t => `
412 <div class="link-task-item" onclick="GoingsOn.tasks.linkTask('${escAttr(parentTaskId)}', '${escAttr(t.id)}')">
413 <span class="link-task-desc">${esc(t.description)}</span>
414 ${t.projectName ? `<span class="link-task-project">${esc(t.projectName)}</span>` : ''}
415 </div>
416 `).join('');
417
418 const content = `
419 <p class="link-task-prompt">Select a task to link as a subtask. The linked task's completion will sync with this subtask.</p>
420 <div class="link-task-list">
421 ${taskOptions}
422 </div>
423 <div class="form-actions form-actions--top-spaced">
424 <button type="button" class="btn btn-secondary" onclick="GoingsOn.tasks.openSubtasks('${escAttr(parentTaskId)}')">Cancel</button>
425 </div>
426 `;
427 GoingsOn.ui.openModal('Link Task', content);
428 }
429
430 /**
431 * Link an existing task as a subtask of the parent task.
432 * @param {string} parentTaskId - Parent task ID
433 * @param {string} linkedTaskId - Task ID to link as subtask
434 */
435 async function linkTask(parentTaskId, linkedTaskId) {
436 try {
437 await GoingsOn.api.subtasks.addLink(parentTaskId, linkedTaskId);
438 GoingsOn.ui.showToast('Task linked!', 'success');
439 // Reload subtasks modal
440 const task = await GoingsOn.api.tasks.get(parentTaskId);
441 renderSubtasksModal(parentTaskId, task.subtasks || []);
442 } catch (err) {
443 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to link task'), 'error');
444 }
445 }
446
447 function closeSubtasksAndRefresh() {
448 GoingsOn.ui.closeModal();
449 // Refresh task list if we're on the tasks view
450 const currentView = GoingsOn.getCurrentView();
451 if (currentView === 'tasks') {
452 load();
453 }
454 // Refresh project dashboard if we're viewing one
455 const currentProjectId = GoingsOn.getCurrentProjectId();
456 if (currentProjectId) {
457 GoingsOn.projects.loadDashboard(currentProjectId);
458 }
459 }
460
461 async function addSubtask(e, taskId) {
462 e.preventDefault();
463 const form = e.target;
464 const text = form.subtask_text.value.trim();
465 if (!text) return;
466
467 try {
468 await GoingsOn.api.subtasks.add(taskId, text);
469 form.subtask_text.value = '';
470 // Reload the subtasks modal
471 const task = await GoingsOn.api.tasks.get(taskId);
472 renderSubtasksModal(taskId, task.subtasks || []);
473 } catch (err) {
474 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to add subtask'), 'error');
475 }
476 }
477
478 async function toggleSubtask(taskId, subtaskId) {
479 try {
480 await GoingsOn.api.subtasks.toggle(taskId, subtaskId);
481 // Reload the subtasks modal
482 const task = await GoingsOn.api.tasks.get(taskId);
483 renderSubtasksModal(taskId, task.subtasks || []);
484 } catch (err) {
485 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to toggle subtask'), 'error');
486 }
487 }
488
489 async function deleteSubtask(taskId, subtaskId) {
490 const confirmed = await GoingsOn.ui.confirmDelete('subtask');
491 if (!confirmed) return;
492
493 try {
494 await GoingsOn.api.subtasks.delete(taskId, subtaskId);
495 // Reload the subtasks modal
496 const task = await GoingsOn.api.tasks.get(taskId);
497 renderSubtasksModal(taskId, task.subtasks || []);
498 } catch (err) {
499 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to delete subtask'), 'error');
500 }
501 }
502
503 /**
504 * Open a form modal to add an annotation note to a task.
505 * @param {string} taskId - Task ID to annotate
506 */
507 function addAnnotation(taskId) {
508 GoingsOn.ui.openFormModal({
509 title: 'Add Annotation',
510 entityType: 'annotation',
511 isEdit: false,
512 fields: [
513 {
514 name: 'note',
515 type: 'textarea',
516 label: 'Note',
517 placeholder: 'Enter annotation note...',
518 required: true,
519 validate: (v) => v && v.length > 2000 ? 'Maximum 2000 characters' : null,
520 },
521 ],
522 onSubmit: async (data) => {
523 await GoingsOn.ui.apiCall(GoingsOn.api.annotations.add(taskId, data.note.trim()), {
524 successMessage: 'Annotation added!',
525 errorMessage: 'Failed to add annotation',
526 });
527 },
528 });
529 }
530
531 /**
532 * Open a modal to assign or change a task's milestone.
533 * @param {string} taskId - Task ID to set milestone on
534 */
535 async function openSetMilestone(taskId) {
536 try {
537 const task = await GoingsOn.api.tasks.get(taskId);
538 if (!task) {
539 GoingsOn.ui.showToast('Task not found', 'error');
540 return;
541 }
542
543 if (!task.projectId) {
544 GoingsOn.ui.showToast('Task must be in a project first', 'error');
545 return;
546 }
547
548 const milestones = await GoingsOn.api.milestones.list(task.projectId);
549 if (milestones.length === 0) {
550 GoingsOn.ui.showToast('No milestones in this project', 'info');
551 return;
552 }
553
554 const milestoneOptions = [
555 { value: '', label: 'None' },
556 ...milestones.map(m => ({
557 value: m.id,
558 label: m.name,
559 selected: m.id === task.milestoneId,
560 })),
561 ];
562
563 GoingsOn.ui.openFormModal({
564 title: 'Set Milestone',
565 entityType: 'set-milestone',
566 isEdit: false,
567 fields: [
568 { name: 'milestone_id', type: 'select', label: 'Milestone', options: milestoneOptions, value: task.milestoneId || '' },
569 ],
570 onSubmit: async (data) => {
571 const input = {
572 description: task.description,
573 projectId: task.projectId,
574 status: task.status,
575 priority: task.priority,
576 due: task.due,
577 tags: task.tags,
578 recurrence: task.recurrence,
579 contactId: task.contactId || null,
580 milestoneId: data.milestone_id || null,
581 };
582
583 const reloadFns = [load];
584 const currentProjectId = GoingsOn.getCurrentProjectId();
585 if (currentProjectId) {
586 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
587 }
588
589 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.update(taskId, input), {
590 successMessage: 'Milestone updated!',
591 errorMessage: 'Failed to set milestone',
592 reload: reloadFns,
593 });
594 },
595 });
596 } catch (err) {
597 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
598 }
599 }
600
601 /**
602 * Transition a task from Pending to Started status.
603 * @param {string} id - Task ID to start
604 */
605 async function start(id) {
606 GoingsOn.cache.invalidate('tasks');
607 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.start(id), {
608 successMessage: 'Task started!',
609 errorMessage: 'Failed to start task',
610 reload: load,
611 });
612 }
613
614 /**
615 * Mark a task as completed. Spawns next instance for recurring tasks.
616 * @param {string} id - Task ID to complete
617 */
618 async function complete(id) {
619 GoingsOn.cache.invalidate('tasks');
620
621 // Animate row out before removing from state
622 const row = document.querySelector(`.task-row[data-id="${id}"]`);
623 if (row) row.classList.add('task-row-removing');
624
625 const cachedTasks = GoingsOn.state.tasks;
626 const removedTask = cachedTasks.find(t => t.id === id);
627
628 // Delay state update to let animation play
629 setTimeout(() => {
630 GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
631 }, 250);
632
633 GoingsOn.ui.showUndoToast('Task completed', {
634 onConfirm: async () => {
635 try {
636 await GoingsOn.api.tasks.complete(id);
637 load();
638 } catch (err) {
639 GoingsOn.ui.showToast('Failed to complete task', 'error');
640 load();
641 }
642 },
643 onUndo: () => {
644 if (removedTask) {
645 GoingsOn.state.set('tasks', [...GoingsOn.state.tasks, removedTask]);
646 }
647 },
648 });
649 }
650
651 /**
652 * Delete a task with confirmation and undo support.
653 * Optimistically removes from UI; actual deletion happens after undo window.
654 * @param {string} id - Task ID to delete
655 */
656 async function deleteTask(id) {
657 if (!await GoingsOn.ui.confirmDelete('task')) return;
658
659 GoingsOn.cache.invalidate('tasks');
660 // Hide task from UI immediately
661 const cachedTasks = GoingsOn.state.tasks;
662 const removedTask = cachedTasks.find(t => t.id === id);
663 GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
664
665 GoingsOn.ui.showUndoToast('Task deleted', {
666 onConfirm: async () => {
667 // Actually delete after undo window expires
668 try {
669 await GoingsOn.api.tasks.delete(id);
670 } catch (err) {
671 GoingsOn.ui.showToast('Failed to delete task', 'error');
672 load();
673 }
674 },
675 onUndo: () => {
676 // Restore task in UI
677 if (removedTask) {
678 GoingsOn.state.set('tasks', [...GoingsOn.state.tasks, removedTask]);
679 }
680 },
681 });
682 }
683
684 // ============ Populate GoingsOn.tasks Namespace ============
685
686 GoingsOn.tasks = {
687 load,
688 renderFilteredTasks,
689 setViewMode: (...a) => GoingsOn.taskBoard.setViewMode(...a),
690 renderBoard: (...a) => GoingsOn.taskBoard.renderBoard(...a),
691 openNew,
692 openNewForProject,
693 create,
694 openActions,
695 openEdit,
696 update,
697 openSubtasks,
698 renderSubtasksModal,
699 closeSubtasksAndRefresh,
700 addSubtask,
701 toggleSubtask,
702 deleteSubtask,
703 addAnnotation,
704 openSetMilestone,
705 openLinkTaskPicker,
706 linkTask,
707 start,
708 complete,
709 delete: deleteTask,
710 // Delegated to tasksFilter
711 populateProjectFilter: (...a) => GoingsOn.tasksFilter.populateProjectFilter(...a),
712 populateMilestoneFilter: (...a) => GoingsOn.tasksFilter.populateMilestoneFilter(...a),
713 getFilters: (...a) => GoingsOn.tasksFilter.getFilters(...a),
714 applyFilters: (...a) => GoingsOn.tasksFilter.applyFilters(...a),
715 clearFilters: (...a) => GoingsOn.tasksFilter.clearFilters(...a),
716 goToPage: (...a) => GoingsOn.tasksFilter.goToPage(...a),
717 toggleSelection: (...a) => GoingsOn.tasksFilter.toggleSelection(...a),
718 selectAll: (...a) => GoingsOn.tasksFilter.selectAll(...a),
719 getSelected: (...a) => GoingsOn.tasksFilter.getSelected(...a),
720 clearSelected: (...a) => GoingsOn.tasksFilter.clearSelected(...a),
721 sort: (...a) => GoingsOn.tasksFilter.sort(...a),
722 toggleMobileFilters: (...a) => GoingsOn.tasksFilter.toggleMobileFilters(...a),
723 mobileSortChange: (...a) => GoingsOn.tasksFilter.mobileSortChange(...a),
724 // Helpers
725 renderTaskBadges,
726 renderTaskRow,
727 formatRecurrence,
728 getFormFields: getTaskFormFields,
729 // Expose managers
730 selection: taskSelection,
731 pagination: taskPagination,
732 // Virtual scroller getter
733 getScroller: () => taskScroller,
734 };
735
736 })();
737