/** * GoingsOn - Tasks Module * Task CRUD, subtasks, annotations, event handlers. * Form definitions live in task-forms.js. * Board/view mode live in task-board.js. * Filter/sort/pagination/selection live in tasks-filter.js. * Row rendering lives in tasks-render.js. */ // ============ Tasks Module ============ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttrValue; const escArg = GoingsOn.utils.escapeHandlerArg; // ============ Task Selection & Pagination ============ const taskSelection = new GoingsOn.SelectionManager('task', '#task-list-container', 'task-bulk-actions'); const taskPagination = new GoingsOn.PaginationManager('task', GoingsOn.state.itemsPerPage); // Virtual scroller instance let taskScroller = null; // CHRONIC-E structural fix (ultra-fuzz Run #28 S3/S4). The state pub/sub was // dead — not one subscriber existed, so optimistic mutations that called // state.set('tasks', …) never re-rendered, and handlers fell back to a full // reload (refetch page 1, scroll to top) on every single-item edit. Wire // exactly one subscriber so any task-state change refreshes the scroller in // place. `suppressTaskRender` gates it during the full rebuild in // renderFilteredTasks: that path sets state to raw (un-decorated) rows mid-way // and (re)creates the scroller on the next line, so a subscriber-driven // refresh there would render half-built rows — the re-entrancy trap flagged in // the GO todo. Outside that window, set() is the render trigger. let suppressTaskRender = false; GoingsOn.state.subscribe('tasks', () => { if (suppressTaskRender) return; if (taskScroller) taskScroller.refresh(); }); // Incremental pagination state. The task list streams in pages as the user // scrolls (via the scroller's onNeedMore hook) instead of capping at a fixed // row count, so a large task set is fully reachable. baseFilters holds the // current filter/sort so each page request is consistent. const TASK_PAGE_SIZE = 200; const taskPaging = { loadedCount: 0, total: 0, baseFilters: null }; /** Attach the display-only fields the row renderer expects. */ function _decorateTasks(tasks) { return tasks.map(t => ({ ...t, displayDescription: t.description })); } /** * Fetch and append the next page of tasks. Wired to the scroller's * onNeedMore hook. No-ops once every task is loaded (leaving the trigger * disarmed); on error it surfaces a toast and stops paging rather than * spinning. */ async function loadMoreTasks() { if (taskPaging.loadedCount >= taskPaging.total || !taskPaging.baseFilters) return; let response; try { response = await GoingsOn.api.tasks.listFiltered({ ...taskPaging.baseFilters, offset: taskPaging.loadedCount, limit: TASK_PAGE_SIZE, }); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load more tasks'), 'error'); return; } const all = (GoingsOn.state.tasks || []).concat(_decorateTasks(response.tasks)); GoingsOn.state.set('tasks', all); taskPaging.loadedCount = all.length; taskPaging.total = response.total; taskSelection.setItems(all); _updateTaskCount(taskPaging.total, taskPaging.loadedCount); // Re-arms the onNeedMore trigger so the next page can load on further scroll. if (taskScroller) taskScroller.refresh(); } // ============ Delegated references ============ const getTaskFormFields = (...args) => GoingsOn.taskForms.getTaskFormFields(...args); const setupMilestoneSelect = (...args) => GoingsOn.taskForms.setupMilestoneSelect(...args); const renderTaskBadges = (...args) => GoingsOn.tasksRender.renderTaskBadges(...args); const formatRecurrence = (...args) => GoingsOn.tasksRender.formatRecurrence(...args); const renderTaskRow = (...args) => GoingsOn.tasksRender.renderTaskRow(...args); const renderSubtasksModal = (...args) => GoingsOn.tasksRender.renderSubtasksModal(...args); const renderStatusTokensModal = (...args) => GoingsOn.tasksRender.renderStatusTokensModal(...args); // ============ Core Functions ============ /** * Load and render the task list for the current view mode (list or board). */ async function load() { if (GoingsOn.cache.isFresh('tasks')) return; GoingsOn.tasksFilter.populateProjectFilter(); // Phase 7 Tier 4 — restore filter state from URL on every load. This // makes reload, deep-link, and back-button preserve the user's view. // populateProjectFilter must run first so the project