Skip to main content

max / goingson

38.6 KB · 935 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.escapeAttrValue;
16 const escArg = GoingsOn.utils.escapeHandlerArg;
17
18 // ============ Task Selection & Pagination ============
19
20 const taskSelection = new GoingsOn.SelectionManager('task', '#task-list-container', 'task-bulk-actions');
21 const taskPagination = new GoingsOn.PaginationManager('task', GoingsOn.state.itemsPerPage);
22
23 // Virtual scroller instance
24 let taskScroller = null;
25
26 // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). The state pub/sub was
27 // dead — not one subscriber existed, so optimistic mutations that called
28 // state.set('tasks', …) never re-rendered, and handlers fell back to a full
29 // reload (refetch page 1, scroll to top) on every single-item edit. Wire
30 // exactly one subscriber so any task-state change refreshes the scroller in
31 // place. `suppressTaskRender` gates it during the full rebuild in
32 // renderFilteredTasks: that path sets state to raw (un-decorated) rows mid-way
33 // and (re)creates the scroller on the next line, so a subscriber-driven
34 // refresh there would render half-built rows — the re-entrancy trap flagged in
35 // the GO todo. Outside that window, set() is the render trigger.
36 let suppressTaskRender = false;
37 GoingsOn.state.subscribe('tasks', () => {
38 if (suppressTaskRender) return;
39 if (taskScroller) taskScroller.refresh();
40 });
41
42 // Incremental pagination state. The task list streams in pages as the user
43 // scrolls (via the scroller's onNeedMore hook) instead of capping at a fixed
44 // row count, so a large task set is fully reachable. baseFilters holds the
45 // current filter/sort so each page request is consistent.
46 const TASK_PAGE_SIZE = 200;
47 const taskPaging = { loadedCount: 0, total: 0, baseFilters: null };
48
49 /** Attach the display-only fields the row renderer expects. */
50 function _decorateTasks(tasks) {
51 return tasks.map(t => ({ ...t, displayDescription: t.description }));
52 }
53
54 /**
55 * Fetch and append the next page of tasks. Wired to the scroller's
56 * onNeedMore hook. No-ops once every task is loaded (leaving the trigger
57 * disarmed); on error it surfaces a toast and stops paging rather than
58 * spinning.
59 */
60 async function loadMoreTasks() {
61 if (taskPaging.loadedCount >= taskPaging.total || !taskPaging.baseFilters) return;
62 let response;
63 try {
64 response = await GoingsOn.api.tasks.listFiltered({
65 ...taskPaging.baseFilters,
66 offset: taskPaging.loadedCount,
67 limit: TASK_PAGE_SIZE,
68 });
69 } catch (err) {
70 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more tasks'), 'error');
71 return;
72 }
73 const all = (GoingsOn.state.tasks || []).concat(_decorateTasks(response.tasks));
74 GoingsOn.state.set('tasks', all);
75 taskPaging.loadedCount = all.length;
76 taskPaging.total = response.total;
77 taskSelection.setItems(all);
78 _updateTaskCount(taskPaging.total, taskPaging.loadedCount);
79 // Re-arms the onNeedMore trigger so the next page can load on further scroll.
80 if (taskScroller) taskScroller.refresh();
81 }
82
83 // ============ Delegated references ============
84
85 const getTaskFormFields = (...args) => GoingsOn.taskForms.getTaskFormFields(...args);
86 const setupMilestoneSelect = (...args) => GoingsOn.taskForms.setupMilestoneSelect(...args);
87 const renderTaskBadges = (...args) => GoingsOn.tasksRender.renderTaskBadges(...args);
88 const formatRecurrence = (...args) => GoingsOn.tasksRender.formatRecurrence(...args);
89 const renderTaskRow = (...args) => GoingsOn.tasksRender.renderTaskRow(...args);
90 const renderSubtasksModal = (...args) => GoingsOn.tasksRender.renderSubtasksModal(...args);
91 const renderStatusTokensModal = (...args) => GoingsOn.tasksRender.renderStatusTokensModal(...args);
92
93 // ============ Core Functions ============
94
95 /**
96 * Load and render the task list for the current view mode (list or board).
97 */
98 async function load() {
99 if (GoingsOn.cache.isFresh('tasks')) return;
100
101 GoingsOn.tasksFilter.populateProjectFilter();
102 // Phase 7 Tier 4 — restore filter state from URL on every load. This
103 // makes reload, deep-link, and back-button preserve the user's view.
104 // populateProjectFilter must run first so the project <option> exists.
105 GoingsOn.tasksFilter.restoreFiltersFromUrl();
106
107 if (GoingsOn.taskBoard.getViewMode() === 'board') {
108 await GoingsOn.taskBoard.renderBoard();
109 } else {
110 await renderFilteredTasks();
111 }
112
113 GoingsOn.cache.markLoaded('tasks');
114 }
115
116 /**
117 * Fetch filtered/sorted tasks from backend and render via virtual scroller.
118 */
119 /**
120 * Update the "N tasks" count chip in the filter bar. While pages are still
121 * streaming in (shown < total) it reads "X of N"; once everything is loaded
122 * it reads the plain total.
123 */
124 function _updateTaskCount(total, shown) {
125 const el = document.getElementById('task-count');
126 if (!el) return;
127 if (typeof total !== 'number' || total < 0) {
128 el.textContent = '';
129 el.classList.remove('filter-count--capped');
130 return;
131 }
132 const noun = total === 1 ? 'task' : 'tasks';
133 if (shown < total) {
134 el.textContent = `${shown} of ${total} ${noun}`;
135 el.classList.add('filter-count--capped');
136 } else {
137 el.textContent = `${total} ${noun}`;
138 el.classList.remove('filter-count--capped');
139 }
140 }
141
142 async function renderFilteredTasks() {
143 const container = document.getElementById('task-list-container');
144 const uiFilters = GoingsOn.tasksFilter.getFilters();
145
146 // Build the filter/sort query for the backend. Offset/limit are added
147 // per page — the list streams in via the scroller's onNeedMore hook.
148 const backendFilters = {
149 showSnoozed: uiFilters.showSnoozed,
150 waitingOnly: uiFilters.waitingOnly,
151 };
152
153 // Status: UI uses lowercase ('pending'), backend expects capitalized ('Pending')
154 if (uiFilters.status) {
155 backendFilters.status = uiFilters.status.charAt(0).toUpperCase() + uiFilters.status.slice(1);
156 }
157
158 // Project ID
159 if (uiFilters.projectId) {
160 backendFilters.projectId = uiFilters.projectId;
161 }
162
163 // Milestone ID
164 if (uiFilters.milestoneId) {
165 backendFilters.milestoneId = uiFilters.milestoneId;
166 }
167
168 // Priority: UI uses 'H', 'M', 'L', backend expects 'High', 'Medium', 'Low'
169 if (uiFilters.priority) {
170 const priorityMap = { H: 'High', M: 'Medium', L: 'Low' };
171 backendFilters.priority = priorityMap[uiFilters.priority] || uiFilters.priority;
172 }
173
174 // Add sorting parameters (backend handles sorting)
175 backendFilters.sortColumn = GoingsOn.tasksFilter.getSortColumn();
176 backendFilters.sortDirection = GoingsOn.tasksFilter.getSortDirection();
177
178 // Reset paging for the new filter/sort and fetch the first page.
179 taskPaging.baseFilters = backendFilters;
180 taskPaging.loadedCount = 0;
181 taskPaging.total = 0;
182
183 // Suppress the state subscriber for the whole rebuild — see the subscribe()
184 // wiring above. The finally restores it so later optimistic edits re-render.
185 suppressTaskRender = true;
186 try {
187 let response;
188 try {
189 // Fetch the first page of filtered/sorted tasks from the backend.
190 response = await GoingsOn.api.tasks.listFiltered({
191 ...backendFilters,
192 offset: 0,
193 limit: TASK_PAGE_SIZE,
194 });
195 // Update cache with the first page.
196 GoingsOn.state.set('tasks', response.tasks);
197 } catch (err) {
198 container.innerHTML = `<div class="loading loading--error">Failed to load tasks. <button class="btn-link" data-act="tasks.renderFilteredTasks">Try again</button></div>`;
199 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load tasks'), 'error', {
200 action: { label: 'Retry', fn: renderFilteredTasks },
201 duration: 8000,
202 });
203 return;
204 }
205
206 if (response.total === 0) {
207 _updateTaskCount(0, 0);
208 const isDefaultFilter = uiFilters.status === 'pending' && !uiFilters.projectId && !uiFilters.priority && !uiFilters.waitingOnly;
209 let emptyHtml;
210 if (isDefaultFilter) {
211 // Check if user has any tasks at all (completed or otherwise)
212 let hasAnyTasks = false;
213 try {
214 const allResp = await GoingsOn.api.tasks.listFiltered({ offset: 0, limit: 1 });
215 hasAnyTasks = allResp.total > 0;
216 } catch (_) { /* fall through */ }
217
218 if (hasAnyTasks) {
219 // All tasks done — celebration!
220 emptyHtml = GoingsOn.ui.renderEmptyState('All clear. No pending tasks.', null, null, 'tasks');
221 } else {
222 // Brand new user — onboarding
223 emptyHtml = GoingsOn.ui.renderEmptyState('No tasks yet.', 'New Task', 'tasks.openNew', 'tasks');
224 }
225 } else {
226 emptyHtml = GoingsOn.ui.renderEmptyState('No tasks match the current filters.', 'New Task', 'tasks.openNew');
227 }
228 container.innerHTML = emptyHtml;
229 // Hide pagination when using virtual scrolling
230 const paginationEl = document.getElementById('task-pagination');
231 if (paginationEl) paginationEl.classList.add('hidden');
232 // Destroy existing scroller
233 if (taskScroller) {
234 taskScroller.destroy();
235 taskScroller = null;
236 }
237 return;
238 }
239
240 // Backend already returns tasks sorted by sortColumn/sortDirection.
241 const displayTasks = _decorateTasks(response.tasks);
242 taskPaging.loadedCount = displayTasks.length;
243 taskPaging.total = response.total;
244
245 // Phase 7 Tier 2 #9 — surface the count so users can see "247 tasks";
246 // reads "X of N" while later pages stream in on scroll.
247 _updateTaskCount(taskPaging.total, taskPaging.loadedCount);
248
249 // Update state with sorted tasks (already sorted by backend)
250 GoingsOn.state.set('tasks', displayTasks);
251
252 // Update selection manager with current items for data-based range selection
253 taskSelection.setItems(displayTasks);
254
255 // Update sort arrow UI
256 GoingsOn.tasksFilter.updateSortArrows();
257
258 // Hide pagination - virtual scrolling replaces it
259 const paginationEl = document.getElementById('task-pagination');
260 if (paginationEl) paginationEl.classList.add('hidden');
261
262 // Initialize or refresh virtual scroller. onNeedMore streams later pages
263 // in as the user scrolls toward the tail.
264 if (!taskScroller) {
265 taskScroller = new GoingsOn.VirtualScroller({
266 container: container,
267 renderItem: renderTaskRow,
268 getItems: () => GoingsOn.state.tasks || [],
269 rowHeight: { estimated: 52, measure: true },
270 overscan: 5,
271 onNeedMore: loadMoreTasks,
272 });
273 } else {
274 taskScroller.refresh();
275 }
276 } finally {
277 suppressTaskRender = false;
278 }
279 }
280
281 function openNew() {
282 GoingsOn.ui.openFormModal({
283 title: 'New Task',
284 entityType: 'task',
285 isEdit: false,
286 fields: getTaskFormFields(),
287 onSubmit: create,
288 });
289 setupMilestoneSelect('task', 'new');
290 GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence');
291 }
292
293 /**
294 * Open the new task form modal pre-filled for a specific project.
295 * @param {string} projectId - Project to pre-select in the form
296 */
297 function openNewForProject(projectId) {
298 GoingsOn.ui.openFormModal({
299 title: 'New Task',
300 entityType: 'task',
301 isEdit: false,
302 fields: getTaskFormFields(null, projectId),
303 onSubmit: create,
304 });
305 setupMilestoneSelect('task', 'new', projectId);
306 }
307
308 /**
309 * Create a new task from form data.
310 * @param {Object} data - Form data with description, project_id, priority, due, tags, etc.
311 */
312 async function create(data) {
313 const tagsValue = data.tags?.trim() || '';
314 const form = document.querySelector('.modal-content form');
315 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'task', data.recurrence) : null;
316 const input = {
317 description: data.description,
318 projectId: data.project_id || null,
319 priority: data.priority,
320 due: data.due ? new Date(data.due).toISOString() : null,
321 tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [],
322 recurrence: data.recurrence,
323 recurrenceRule,
324 contactId: data.contact_id || null,
325 milestoneId: data.milestone_id || null,
326 estimatedMinutes: data.estimated_minutes ? parseInt(data.estimated_minutes, 10) : null,
327 };
328
329 const reloadFns = [load];
330 const currentProjectId = GoingsOn.getCurrentProjectId();
331 if (currentProjectId) {
332 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
333 }
334
335 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.create(input), {
336 successMessage: 'Task created!',
337 errorMessage: 'Failed to create task',
338 onSuccess: () => GoingsOn.cache.invalidate('tasks'),
339 reload: reloadFns,
340 });
341 }
342
343 /**
344 * Open the task actions modal with start, complete, edit, snooze, etc.
345 * @param {string} id - Task ID
346 */
347 async function openActions(id) {
348 const task = await GoingsOn.api.tasks.get(id);
349 const isSnoozed = task && task.isSnoozed;
350
351 const content = `
352 <div class="stack stack-2">
353 <button class="btn btn-secondary" data-act="tasks.start" data-a1="${escAttr(id)}">Start Task</button>
354 <button class="btn btn-secondary" data-act="tasks.complete" data-a1="${escAttr(id)}">Complete Task</button>
355 <button class="btn btn-secondary" data-act="tasks.openEdit" data-a1="${escAttr(id)}">Edit Task</button>
356 <button class="btn btn-secondary" data-act="tasks.openSubtasks" data-a1="${escAttr(id)}">Manage Subtasks</button>
357 <button class="btn btn-secondary" data-act="tasks.addAnnotation" data-a1="${escAttr(id)}">Add Note</button>
358 <button class="btn btn-secondary" data-act="attachments.openPanel" data-a1="${escAttr(id)}" data-args='["@a1", null]'>Attachments</button>
359 <hr class="hr-soft">
360 ${isSnoozed
361 ? `<button class="btn btn-secondary" data-act="snooze.unsnooze" data-a1="task" data-a2="${escAttr(id)}">Unsnooze Task</button>`
362 : `<button class="btn btn-secondary" data-act="snooze.openModal" data-a1="task" data-a2="${escAttr(id)}">Snooze Task</button>`
363 }
364 <button class="btn btn-secondary" data-act="dayPlan.openScheduleTaskModal" data-a1="${escAttr(id)}">Schedule Time Block</button>
365 <button class="btn btn-secondary" data-act="ui.closeModalThen" data-a1="timeTracking.startTimer" data-a2="${escAttr(id)}">Track Time</button>
366 <button class="btn btn-secondary" data-act="ui.closeModalThen" data-a1="focusTimer.start" data-a2="${escAttr(id)}">Focus Mode (25/5)</button>
367 <hr class="hr-soft">
368 <button class="btn btn-secondary text-accent-red" data-act="tasks.delete" data-a1="${escAttr(id)}">Delete Task</button>
369 </div>
370 `;
371 GoingsOn.ui.openModal('Task Actions', content);
372 }
373
374 /**
375 * Fetch a task and open the edit form modal.
376 * @param {string} id - Task ID to edit
377 */
378 async function openEdit(id) {
379 try {
380 const task = await GoingsOn.api.tasks.get(id);
381 if (!task) {
382 GoingsOn.ui.showToast('Task not found', 'error');
383 return;
384 }
385
386 const extraContent = task.source_email_id ? `
387 <div class="form-group source-email-link">
388 <label class="form-label">Source Email</label>
389 <button type="button" class="btn btn-secondary" data-act="ui.closeModalThen" data-a1="emails.open" data-a2="${escAttr(task.source_email_id)}">
390 View Related Email
391 </button>
392 </div>
393 ` : '';
394
395 GoingsOn.ui.openFormModal({
396 title: 'Edit Task',
397 entityType: 'task',
398 isEdit: true,
399 entityId: id,
400 fields: getTaskFormFields(task),
401 onSubmit: (data) => update(id, data),
402 extraContent,
403 });
404 setupMilestoneSelect('task', 'edit', task.projectId, task.milestoneId);
405 GoingsOn.taskForms.initRecurrenceConfig('task', 'recurrence');
406 } catch (err) {
407 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
408 }
409 }
410
411 /**
412 * Update an existing task from form data.
413 * @param {string} id - Task ID to update
414 * @param {Object} data - Form data with description, project_id, status, priority, etc.
415 */
416 async function update(id, data) {
417 const tagsValue = data.tags?.trim() || '';
418 const form = document.querySelector('.modal-content form');
419 const recurrenceRule = form ? GoingsOn.taskForms.collectRecurrenceRule(form, 'task', data.recurrence) : null;
420 const input = {
421 description: data.description,
422 projectId: data.project_id || null,
423 status: data.status,
424 priority: data.priority,
425 due: data.due ? new Date(data.due).toISOString() : null,
426 tags: tagsValue ? tagsValue.split(',').map(t => t.trim()).filter(t => t) : [],
427 recurrence: data.recurrence,
428 recurrenceRule,
429 contactId: data.contact_id || null,
430 milestoneId: data.milestone_id || null,
431 estimatedMinutes: data.estimated_minutes ? parseInt(data.estimated_minutes, 10) : null,
432 };
433
434 const reloadFns = [load];
435 const currentProjectId = GoingsOn.getCurrentProjectId();
436 if (currentProjectId) {
437 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
438 }
439
440 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.update(id, input), {
441 successMessage: 'Task updated!',
442 errorMessage: 'Failed to update task',
443 onSuccess: () => GoingsOn.cache.invalidate('tasks'),
444 reload: reloadFns,
445 });
446 }
447
448 /**
449 * Fetch a task's subtasks and open the subtasks management modal.
450 * @param {string} taskId - Task ID to manage subtasks for
451 */
452 async function openSubtasks(taskId) {
453 try {
454 const task = await GoingsOn.api.tasks.get(taskId);
455 if (!task) {
456 GoingsOn.ui.showToast('Task not found', 'error');
457 return;
458 }
459
460 renderSubtasksModal(taskId, task.subtasks || []);
461 } catch (err) {
462 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
463 }
464 }
465
466 /**
467 * Open a picker modal to link another task as a subtask.
468 * @param {string} parentTaskId - Parent task to link a subtask to
469 */
470 async function openLinkTaskPicker(parentTaskId) {
471 // Get all pending/started tasks (excluding the parent task)
472 const filters = { status: 'Pending', limit: 100 };
473 const response = await GoingsOn.api.tasks.listFiltered(filters);
474 const availableTasks = response.tasks.filter(t => t.id !== parentTaskId);
475
476 if (availableTasks.length === 0) {
477 GoingsOn.ui.showToast('No other tasks available to link', 'info');
478 return;
479 }
480
481 const taskOptions = availableTasks.map(t => `
482 <div class="link-task-item" data-act="tasks.linkTask" data-a1="${escAttr(parentTaskId)}" data-a2="${escAttr(t.id)}">
483 <span class="link-task-desc">${esc(t.description)}</span>
484 ${t.projectName ? `<span class="link-task-project">${esc(t.projectName)}</span>` : ''}
485 </div>
486 `).join('');
487
488 const content = `
489 <p class="link-task-prompt">Select a task to link as a subtask. The linked task's completion will sync with this subtask.</p>
490 <div class="link-task-list">
491 ${taskOptions}
492 </div>
493 <div class="form-actions form-actions--top-spaced">
494 <button type="button" class="btn btn-secondary" data-act="tasks.openSubtasks" data-a1="${escAttr(parentTaskId)}">Cancel</button>
495 </div>
496 `;
497 GoingsOn.ui.openModal('Link Task', content);
498 }
499
500 /**
501 * Link an existing task as a subtask of the parent task.
502 * @param {string} parentTaskId - Parent task ID
503 * @param {string} linkedTaskId - Task ID to link as subtask
504 */
505 async function linkTask(parentTaskId, linkedTaskId) {
506 try {
507 await GoingsOn.api.subtasks.addLink(parentTaskId, linkedTaskId);
508 GoingsOn.ui.showToast('Task linked!', 'success');
509 // Reload subtasks modal
510 const task = await GoingsOn.api.tasks.get(parentTaskId);
511 renderSubtasksModal(parentTaskId, task.subtasks || []);
512 } catch (err) {
513 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to link task'), 'error');
514 }
515 }
516
517 function closeSubtasksAndRefresh() {
518 GoingsOn.ui.closeModal();
519 // Refresh task list if we're on the tasks view
520 const currentView = GoingsOn.getCurrentView();
521 if (currentView === 'tasks') {
522 load();
523 }
524 // Refresh project dashboard if we're viewing one
525 const currentProjectId = GoingsOn.getCurrentProjectId();
526 if (currentProjectId) {
527 GoingsOn.projects.loadDashboard(currentProjectId);
528 }
529 }
530
531 async function addSubtask(e, taskId) {
532 e.preventDefault();
533 const form = e.target;
534 const text = form.subtask_text.value.trim();
535 if (!text) return;
536
537 try {
538 await GoingsOn.api.subtasks.add(taskId, text);
539 form.subtask_text.value = '';
540 // Reload the subtasks modal
541 const task = await GoingsOn.api.tasks.get(taskId);
542 renderSubtasksModal(taskId, task.subtasks || []);
543 } catch (err) {
544 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to add subtask'), 'error');
545 }
546 }
547
548 async function toggleSubtask(taskId, subtaskId) {
549 try {
550 await GoingsOn.api.subtasks.toggle(taskId, subtaskId);
551 // Reload the subtasks modal
552 const task = await GoingsOn.api.tasks.get(taskId);
553 renderSubtasksModal(taskId, task.subtasks || []);
554 } catch (err) {
555 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to toggle subtask'), 'error');
556 }
557 }
558
559 async function deleteSubtask(taskId, subtaskId) {
560 const confirmed = await GoingsOn.ui.confirmDelete('subtask');
561 if (!confirmed) return;
562
563 try {
564 await GoingsOn.api.subtasks.delete(taskId, subtaskId);
565 // Reload the subtasks modal
566 const task = await GoingsOn.api.tasks.get(taskId);
567 renderSubtasksModal(taskId, task.subtasks || []);
568 } catch (err) {
569 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to delete subtask'), 'error');
570 }
571 }
572
573 // ============ Status tokens (commits) ============
574
575 /**
576 * Fetch a task's status tokens and open the commits manager modal.
577 * @param {string} taskId - Task ID
578 */
579 async function openStatusTokens(taskId) {
580 try {
581 const task = await GoingsOn.api.tasks.get(taskId);
582 if (!task) {
583 GoingsOn.ui.showToast('Task not found', 'error');
584 return;
585 }
586 renderStatusTokensModal(taskId, task.statusTokens || []);
587 } catch (err) {
588 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
589 }
590 }
591
592 /** Re-fetch the task and re-render the open commits modal. */
593 async function reloadStatusTokens(taskId) {
594 const task = await GoingsOn.api.tasks.get(taskId);
595 renderStatusTokensModal(taskId, task.statusTokens || []);
596 }
597
598 /**
599 * Add a commit reference to a task (starts Pending / not-primary).
600 * @param {Event} e - Submit event
601 * @param {string} taskId - Task ID
602 */
603 async function addStatusToken(e, taskId) {
604 e.preventDefault();
605 const form = e.target;
606 const reference = form.commit_ref.value.trim();
607 if (!reference) return;
608
609 try {
610 // Only the `commit` kind exists today; the add form is commit-specific.
611 await GoingsOn.api.statusTokens.record(taskId, { kind: 'commit', reference, state: 'Pending', isPrimary: false });
612 form.commit_ref.value = '';
613 await reloadStatusTokens(taskId);
614 } catch (err) {
615 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to add commit'), 'error');
616 }
617 }
618
619 /**
620 * Record a token with an explicit (pushed, primary) state — the toggle path.
621 * `record` is idempotent in (kind, reference), so this updates in place.
622 * @param {string} taskId
623 * @param {string} kind
624 * @param {string} reference
625 * @param {string} pushedStr - "1" for pushed (Complete), else Pending
626 * @param {string} primaryStr - "1" for primary
627 */
628 async function setStatusToken(taskId, kind, reference, pushedStr, primaryStr) {
629 try {
630 await GoingsOn.api.statusTokens.record(taskId, {
631 kind,
632 reference,
633 state: pushedStr === '1' ? 'Complete' : 'Pending',
634 isPrimary: primaryStr === '1',
635 });
636 await reloadStatusTokens(taskId);
637 } catch (err) {
638 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to update commit'), 'error');
639 }
640 }
641
642 /**
643 * Remove a status token from a task.
644 * @param {string} taskId
645 * @param {string} tokenId
646 */
647 async function deleteStatusToken(taskId, tokenId) {
648 try {
649 await GoingsOn.api.statusTokens.delete(tokenId);
650 await reloadStatusTokens(taskId);
651 } catch (err) {
652 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to remove commit'), 'error');
653 }
654 }
655
656 /**
657 * Open a form modal to add an annotation note to a task.
658 * @param {string} taskId - Task ID to annotate
659 */
660 function addAnnotation(taskId) {
661 GoingsOn.ui.openFormModal({
662 title: 'Add Annotation',
663 entityType: 'annotation',
664 isEdit: false,
665 fields: [
666 {
667 name: 'note',
668 type: 'textarea',
669 label: 'Note',
670 placeholder: 'Enter annotation note...',
671 required: true,
672 validate: (v) => v && v.length > 2000 ? 'Maximum 2000 characters' : null,
673 },
674 ],
675 onSubmit: async (data) => {
676 await GoingsOn.ui.apiCall(GoingsOn.api.annotations.add(taskId, data.note.trim()), {
677 successMessage: 'Annotation added!',
678 errorMessage: 'Failed to add annotation',
679 });
680 },
681 });
682 }
683
684 /**
685 * Open a modal to assign or change a task's milestone.
686 * @param {string} taskId - Task ID to set milestone on
687 */
688 async function openSetMilestone(taskId) {
689 try {
690 const task = await GoingsOn.api.tasks.get(taskId);
691 if (!task) {
692 GoingsOn.ui.showToast('Task not found', 'error');
693 return;
694 }
695
696 if (!task.projectId) {
697 GoingsOn.ui.showToast('Task must be in a project first', 'error');
698 return;
699 }
700
701 const milestones = await GoingsOn.api.milestones.list(task.projectId);
702 if (milestones.length === 0) {
703 GoingsOn.ui.showToast('No milestones in this project', 'info');
704 return;
705 }
706
707 const milestoneOptions = [
708 { value: '', label: 'None' },
709 ...milestones.map(m => ({
710 value: m.id,
711 label: m.name,
712 selected: m.id === task.milestoneId,
713 })),
714 ];
715
716 GoingsOn.ui.openFormModal({
717 title: 'Set Milestone',
718 entityType: 'set-milestone',
719 isEdit: false,
720 fields: [
721 { name: 'milestone_id', type: 'select', label: 'Milestone', options: milestoneOptions, value: task.milestoneId || '' },
722 ],
723 onSubmit: async (data) => {
724 const input = {
725 description: task.description,
726 projectId: task.projectId,
727 status: task.status,
728 priority: task.priority,
729 due: task.due,
730 tags: task.tags,
731 recurrence: task.recurrence,
732 contactId: task.contactId || null,
733 milestoneId: data.milestone_id || null,
734 };
735
736 const reloadFns = [load];
737 const currentProjectId = GoingsOn.getCurrentProjectId();
738 if (currentProjectId) {
739 reloadFns.push(() => GoingsOn.projects.loadDashboard(currentProjectId));
740 }
741
742 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.update(taskId, input), {
743 successMessage: 'Milestone updated!',
744 errorMessage: 'Failed to set milestone',
745 reload: reloadFns,
746 });
747 },
748 });
749 } catch (err) {
750 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load task'), 'error');
751 }
752 }
753
754 /**
755 * Transition a task from Pending to Started status.
756 * @param {string} id - Task ID to start
757 */
758 /**
759 * Surgically reflect a single task's status change in the streamed list. If
760 * the active status filter no longer matches the new status, the row leaves
761 * the view; otherwise it is patched in place. Either way the state subscriber
762 * refreshes the scroller — no full-window refetch + scroll-to-top (ultra-fuzz
763 * Run #28 S4). Genuine re-sorts (priority/due edits) still reload; a status
764 * transition does not move a task within the urgency sort.
765 * @param {string} id - Task ID
766 * @param {string} newStatus - Capitalized backend status ('Started', etc.)
767 */
768 function _applyTaskStatusChange(id, newStatus) {
769 const filterStatus = GoingsOn.tasksFilter.getFilters().status; // '' = all
770 const tasks = GoingsOn.state.tasks || [];
771 const stillMatches = !filterStatus || filterStatus.toLowerCase() === newStatus.toLowerCase();
772 if (stillMatches) {
773 GoingsOn.state.set('tasks', tasks.map(t => t.id === id ? { ...t, status: newStatus } : t));
774 } else {
775 GoingsOn.state.set('tasks', tasks.filter(t => t.id !== id));
776 }
777 }
778
779 async function start(id) {
780 await GoingsOn.ui.apiCall(GoingsOn.api.tasks.start(id), {
781 successMessage: 'Task started!',
782 errorMessage: 'Failed to start task',
783 onSuccess: () => {
784 GoingsOn.cache.invalidate('tasks');
785 _applyTaskStatusChange(id, 'Started');
786 },
787 });
788 }
789
790 /**
791 * Mark a task as completed. Spawns next instance for recurring tasks.
792 * @param {string} id - Task ID to complete
793 */
794 async function complete(id) {
795 GoingsOn.cache.invalidate('tasks');
796
797 // Animate row out before removing from state
798 const row = document.querySelector(`.task-row[data-id="${id}"]`);
799 if (row) row.classList.add('task-row-removing');
800
801 const cachedTasks = GoingsOn.state.tasks;
802 const removedIndex = cachedTasks.findIndex(t => t.id === id);
803 const removedTask = removedIndex > -1 ? cachedTasks[removedIndex] : null;
804
805 // Optimistic surgical removal once the row has animated out. The state
806 // subscriber refreshes the scroller in place — no full-window refetch
807 // (ultra-fuzz Run #28 S4). The completed task leaves the pending view.
808 setTimeout(() => {
809 GoingsOn.state.set('tasks', GoingsOn.state.tasks.filter(t => t.id !== id));
810 }, 250);
811
812 GoingsOn.ui.showUndoToast('Task completed', {
813 onConfirm: async () => {
814 try {
815 await GoingsOn.api.tasks.complete(id);
816 // A recurring task spawns its next instance on completion, so
817 // refetch to surface it. A plain task is already reflected by the
818 // optimistic removal — no full-window refetch (ultra-fuzz #28 S4).
819 if (removedTask && removedTask.recurrence && removedTask.recurrence !== 'None') {
820 load();
821 }
822 } catch (err) {
823 GoingsOn.ui.showToast('Failed to complete task', 'error');
824 load(); // restore truth after a failed commit
825 }
826 },
827 onUndo: () => {
828 if (removedTask) {
829 // Restore at the original position so the undo doesn't drop the
830 // row to the list tail (ultra-fuzz Run #28 S3).
831 const restored = GoingsOn.state.tasks.slice();
832 restored.splice(Math.min(removedIndex, restored.length), 0, removedTask);
833 GoingsOn.state.set('tasks', restored);
834 }
835 },
836 });
837 }
838
839 /**
840 * Delete a task with confirmation and undo support.
841 * Optimistically removes from UI; actual deletion happens after undo window.
842 * @param {string} id - Task ID to delete
843 */
844 async function deleteTask(id) {
845 // No hard confirm (GO-13): this is an optimistic delete whose API call is
846 // deferred until the undo window expires, so the undo toast IS the
847 // recovery. A "this cannot be undone" modal here would be both redundant
848 // and false. Confirms are reserved for immediate, non-undoable deletes.
849 GoingsOn.cache.invalidate('tasks');
850 // Hide task from UI immediately. The state subscriber refreshes the
851 // scroller in place — no full-window refetch (ultra-fuzz Run #28 S3/S4).
852 const cachedTasks = GoingsOn.state.tasks;
853 const removedIndex = cachedTasks.findIndex(t => t.id === id);
854 const removedTask = removedIndex > -1 ? cachedTasks[removedIndex] : null;
855 GoingsOn.state.set('tasks', cachedTasks.filter(t => t.id !== id));
856
857 GoingsOn.ui.showUndoToast('Task deleted', {
858 onConfirm: async () => {
859 // Actually delete after undo window expires
860 try {
861 await GoingsOn.api.tasks.delete(id);
862 } catch (err) {
863 GoingsOn.ui.showToast('Failed to delete task', 'error');
864 load();
865 }
866 },
867 onUndo: () => {
868 // Restore at the original position (ultra-fuzz Run #28 S3).
869 if (removedTask) {
870 const restored = GoingsOn.state.tasks.slice();
871 restored.splice(Math.min(removedIndex, restored.length), 0, removedTask);
872 GoingsOn.state.set('tasks', restored);
873 }
874 },
875 });
876 }
877
878 // ============ Populate GoingsOn.tasks Namespace ============
879
880 GoingsOn.tasks = {
881 load,
882 renderFilteredTasks,
883 setViewMode: (...a) => GoingsOn.taskBoard.setViewMode(...a),
884 renderBoard: (...a) => GoingsOn.taskBoard.renderBoard(...a),
885 openNew,
886 openNewForProject,
887 create,
888 openActions,
889 openEdit,
890 update,
891 openSubtasks,
892 openStatusTokens,
893 addStatusToken,
894 setStatusToken,
895 deleteStatusToken,
896 renderSubtasksModal,
897 closeSubtasksAndRefresh,
898 addSubtask,
899 toggleSubtask,
900 deleteSubtask,
901 addAnnotation,
902 openSetMilestone,
903 openLinkTaskPicker,
904 linkTask,
905 start,
906 complete,
907 delete: deleteTask,
908 // Delegated to tasksFilter
909 populateProjectFilter: (...a) => GoingsOn.tasksFilter.populateProjectFilter(...a),
910 populateMilestoneFilter: (...a) => GoingsOn.tasksFilter.populateMilestoneFilter(...a),
911 getFilters: (...a) => GoingsOn.tasksFilter.getFilters(...a),
912 applyFilters: (...a) => GoingsOn.tasksFilter.applyFilters(...a),
913 clearFilters: (...a) => GoingsOn.tasksFilter.clearFilters(...a),
914 goToPage: (...a) => GoingsOn.tasksFilter.goToPage(...a),
915 toggleSelection: (...a) => GoingsOn.tasksFilter.toggleSelection(...a),
916 selectAll: (...a) => GoingsOn.tasksFilter.selectAll(...a),
917 getSelected: (...a) => GoingsOn.tasksFilter.getSelected(...a),
918 clearSelected: (...a) => GoingsOn.tasksFilter.clearSelected(...a),
919 sort: (...a) => GoingsOn.tasksFilter.sort(...a),
920 toggleMobileFilters: (...a) => GoingsOn.tasksFilter.toggleMobileFilters(...a),
921 mobileSortChange: (...a) => GoingsOn.tasksFilter.mobileSortChange(...a),
922 // Helpers
923 renderTaskBadges,
924 renderTaskRow,
925 formatRecurrence,
926 getFormFields: getTaskFormFields,
927 // Expose managers
928 selection: taskSelection,
929 pagination: taskPagination,
930 // Virtual scroller getter
931 getScroller: () => taskScroller,
932 };
933
934 })();
935