Skip to main content

max / goingson

16.6 KB · 293 lines History Blame Raw
1 /**
2 * GoingsOn - Tasks Render Module
3 * Task row rendering, badges, subtask modal rendering
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttrValue;
10 const escArg = GoingsOn.utils.escapeHandlerArg;
11 const escAttrVal = GoingsOn.utils.escapeAttrValue;
12
13 // ============ Task Rendering Helpers ============
14
15 /**
16 * Render subtask count and annotation badges for a task row.
17 * @param {Object} task - Task object with subtaskCount, subtaskCompleted, annotations
18 * @returns {string} HTML string of badge elements, or empty string if none
19 */
20 function renderTaskBadges(task) {
21 let badges = '';
22 const subtaskCount = task.subtaskCount;
23 const completedSubtasks = task.subtaskCompleted;
24 const annotationCount = task.annotations ? task.annotations.length : 0;
25
26 if (subtaskCount > 0) {
27 badges += `<span class="task-badge has-items">${completedSubtasks}/${subtaskCount}</span>`;
28 }
29 if (annotationCount > 0) {
30 badges += `<span class="task-badge has-items">Notes: ${annotationCount}</span>`;
31 }
32
33 return badges ? `<span class="task-badges">${badges}</span>` : '';
34 }
35
36 /**
37 * Format a task's recurrence pattern for display.
38 * Only shows recurrence when the task also has a due date.
39 * @param {Object} task - Task object with recurrence and due fields
40 * @returns {string} Recurrence label or '-'
41 */
42 function formatRecurrence(task) {
43 // Only show recurrence if task has both recurrence AND a due date
44 if (task.recurrence && task.recurrence !== 'None' && task.due) {
45 return task.recurrence;
46 }
47 return '-';
48 }
49
50 /**
51 * Render a single task row as a div (for virtual scrolling).
52 * @param {Object} t - Task object
53 * @param {number} index - Task index
54 * @returns {string} HTML string
55 */
56 /**
57 * Format a minute count as a human-readable duration string.
58 * @param {number} mins - Minutes to format
59 * @returns {string} Formatted string (e.g., "1h 30m" or "45m")
60 */
61 function formatMinutes(mins) {
62 if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
63 return `${mins}m`;
64 }
65
66 /**
67 * Render time tracking badge for a task (active timer or tracked/estimated).
68 * @param {Object} t - Task object with timerActive, estimatedMinutes, actualMinutes
69 * @returns {string} HTML string for the time badge, or empty string
70 */
71 function renderTimeBadge(t) {
72 if (t.timerActive) {
73 return '<span class="task-timer-active" title="Timer running"></span>';
74 }
75 if (t.estimatedMinutes || t.actualMinutes) {
76 const actual = t.actualMinutes || 0;
77 const est = t.estimatedMinutes;
78 const isOver = est && actual > est;
79 const label = est ? `${formatMinutes(actual)} / ${formatMinutes(est)}` : formatMinutes(actual);
80 return `<span class="task-time-badge${isOver ? ' over-estimate' : ''}" title="Tracked / Estimated">${esc(label)}</span>`;
81 }
82 return '';
83 }
84
85 /**
86 * Render the at-a-glance status-token dot for a task row. The colour rolls the
87 * task's tokens up: neutral (muted, no tokens), pending (amber, a token is not
88 * yet done — e.g. a commit not pushed), or complete (green, all done). Clicking
89 * opens the commits/token manager.
90 * @param {Object} t - Task with tokenSummary + statusTokens
91 * @returns {string} HTML for the dot button
92 */
93 function renderTokenDot(t) {
94 const count = (t.statusTokens || []).length;
95 // Only tasks with tokens get a dot — a plain "real life" task stays clean.
96 if (count === 0) return '';
97 const summary = t.tokenSummary || 'neutral';
98 let title;
99 if (summary === 'complete') {
100 title = count > 1 ? 'All commits pushed' : 'Commit pushed';
101 } else {
102 title = count > 1 ? 'Commits associated, not all pushed' : 'Commit associated, not pushed';
103 }
104 return `<button class="token-dot token-dot--${summary}" data-act="tasks.openStatusTokens" data-a1="${escAttr(t.id)}" title="${escAttr(title)}" aria-label="${escAttr(title)}"></button>`;
105 }
106
107 function renderTaskRow(t, index) {
108 const progress = t.subtaskProgress ?? 0;
109 const displayDesc = t.displayDescription || t.description;
110 const isSelected = GoingsOn.tasks.selection.isSelected(t.id);
111 const isStarted = t.status === 'Started';
112
113 return `
114 <div class="task-row task-${t.status.toLowerCase()} ${t.isSnoozed ? 'task-snoozed' : ''} ${t.isOverdue ? 'task-overdue' : ''} ${isSelected ? 'selected' : ''}"
115 data-id="${escAttr(t.id)}"
116 data-contextmenu="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}"
117 tabindex="0" role="row">
118 <div class="task-cell task-description" data-act="taskOverview.open" data-a1="${escAttr(t.id)}">
119 ${isStarted ? `<span class="task-started-icon" title="Started - click to track time" data-act="timeTracking.startTimer" data-a1="${escAttr(t.id)}"></span>` : ''}
120 <span class="task-description-text">${esc(displayDesc)}</span>
121 ${renderTokenDot(t)}
122 ${renderTimeBadge(t)}
123 ${renderTaskBadges(t)}
124 ${t.contactName ? `<span class="contact-badge" title="${escAttrVal(t.contactName)}">${esc(t.contactName)}</span>` : ''}
125 ${t.isSnoozed ? `<span class="snooze-badge" title="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}" aria-label="Snoozed until ${escAttr(t.snoozedUntilFormatted || '')}">Snoozed</span>` : ''}
126 </div>
127 <div class="task-cell task-project">${esc(t.projectName) || '-'}</div>
128 <div class="task-cell priority-${t.priority.toLowerCase()}" aria-label="Priority ${t.priority}">${t.priority.charAt(0)}</div>
129 <div class="task-cell task-due">${t.dueFormatted || '-'}</div>
130 <div class="task-cell task-recurrence">${formatRecurrence(t)}</div>
131 <div class="task-cell task-progress">
132 ${t.subtaskCount > 0 ? `
133 <div class="progress-bar-container" title="${t.subtaskCompleted}/${t.subtaskCount} subtasks"
134 role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100"
135 aria-label="${t.subtaskCompleted} of ${t.subtaskCount} subtasks completed">
136 <div class="progress-bar" style="width: ${progress}%"></div>
137 </div>
138 ` : '<span class="no-subtasks">-</span>'}
139 </div>
140 <div class="task-cell task-actions-cell">
141 <input type="checkbox" class="bulk-checkbox" data-id="${escAttr(t.id)}"
142 ${isSelected ? 'checked' : ''}
143 data-change="tasks.toggleSelection" data-a1="${escAttr(t.id)}" data-a2="@el" data-a3="@event"
144 aria-label="Select task">
145 <button class="btn-icon kebab-btn" data-act="contextMenus.showTask" data-a1="@event" data-a2="${escAttr(t.id)}" title="Actions" aria-label="Task actions">&#x22EE;</button>
146 </div>
147 </div>
148 `;
149 }
150
151 /**
152 * Render and open the subtasks management modal for a task.
153 * Shows progress bar, subtask list, add form, and link-task option.
154 * @param {string} taskId - Parent task ID
155 * @param {Array<Object>} subtasks - Array of subtask objects
156 */
157 function renderSubtasksModal(taskId, subtasks) {
158 const completedCount = subtasks.filter(s => s.isCompleted).length;
159 const totalCount = subtasks.length;
160 const progress = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
161
162 const progressBar = totalCount > 0 ? `
163 <div class="mb-1">
164 <div class="text-sm-secondary" style="display: flex; justify-content: space-between; margin-bottom: 0.25rem;">
165 <span>Progress</span>
166 <span>${completedCount}/${totalCount} (${progress}%)</span>
167 </div>
168 <div class="progress-bar-container" style="height: 12px;">
169 <div class="progress-bar" style="width: ${progress}%"></div>
170 </div>
171 </div>
172 ` : '';
173
174 const subtasksList = subtasks.length === 0
175 ? '<p class="subtasks-empty">No subtasks yet. Add one below!</p>'
176 : subtasks.map(s => {
177 const isLinked = s.linkedTaskId || s.linked_task_id;
178 const isCompleted = s.is_completed || s.isCompleted;
179 const linkedTaskId = s.linkedTaskId || s.linked_task_id;
180
181 if (isLinked) {
182 // Linked task subtask - clicking navigates to the linked task
183 return `
184 <div class="subtask-item subtask-item--linked">
185 <input type="checkbox" ${isCompleted ? 'checked' : ''} disabled class="subtask-checkbox-disabled" title="Completion syncs with linked task">
186 <span class="flex-1" style="cursor: pointer;${isCompleted ? ' text-decoration: line-through; opacity: 0.6;' : ''}" data-act="tasks.openSubtasks" data-a1="${escAttr(linkedTaskId)}" title="Click to open linked task">
187 <span class="subtask-linked-tag">[Linked]</span> ${esc(s.text)}
188 </span>
189 <button class="btn btn-sm text-accent-red" data-act="tasks.deleteSubtask" data-a1="${escAttr(taskId)}" data-a2="${escAttr(s.id)}" aria-label="Unlink task">&times;</button>
190 </div>
191 `;
192 } else {
193 // Regular text subtask
194 return `
195 <div class="subtask-item">
196 <input type="checkbox" ${isCompleted ? 'checked' : ''} data-change="tasks.toggleSubtask" data-a1="${escAttr(taskId)}" data-a2="${escAttr(s.id)}" class="subtask-checkbox" aria-label="Toggle subtask completion">
197 <span class="flex-1${isCompleted ? ' subtask-text-done' : ''}">${esc(s.text)}</span>
198 <button class="btn btn-sm text-accent-red" data-act="tasks.deleteSubtask" data-a1="${escAttr(taskId)}" data-a2="${escAttr(s.id)}" aria-label="Delete subtask">&times;</button>
199 </div>
200 `;
201 }
202 }).join('');
203
204 const content = `
205 ${progressBar}
206 <div style="margin-bottom: 1rem; max-height: 300px; overflow-y: auto;">
207 ${subtasksList}
208 </div>
209 <form data-submit="tasks.addSubtask" data-a1="@event" data-a2="${escAttr(taskId)}" class="row-flex row-flex-2">
210 <input type="text" class="form-input flex-1" name="subtask_text" placeholder="Add a subtask...">
211 <button type="submit" class="btn btn-primary">Add</button>
212 </form>
213 <div style="margin-top: 0.5rem;">
214 <button type="button" class="btn btn-secondary" data-act="tasks.openLinkTaskPicker" data-a1="${escAttr(taskId)}" style="width: 100%;">Link Another Task</button>
215 </div>
216 <div class="task-actions-bar">
217 <button type="button" class="btn btn-sm btn-secondary" data-act="tasks.openEdit" data-a1="${escAttr(taskId)}" title="Edit task fields">Edit</button>
218 <button type="button" class="btn btn-sm btn-secondary" data-act="tasks.addAnnotation" data-a1="${escAttr(taskId)}" title="Add a note to this task">Note</button>
219 <button type="button" class="btn btn-sm btn-secondary" data-act="ui.closeModalThen" data-a1="timeTracking.startTimer" data-a2="${escAttr(taskId)}" title="Start live timer">Track Time</button>
220 <button type="button" class="btn btn-sm btn-secondary" data-act="ui.closeModalThen" data-a1="focusTimer.start" data-a2="${escAttr(taskId)}" title="Pomodoro-style focus session">Focus</button>
221 <button type="button" class="btn btn-sm btn-secondary" data-act="dayPlan.openScheduleTaskModal" data-a1="${escAttr(taskId)}" title="Block time on day planner">Schedule</button>
222 <button type="button" class="btn btn-sm btn-secondary" data-act="attachments.openPanel" data-a1="${escAttr(taskId)}" data-args='["@a1", null]' title="Manage file attachments">Files</button>
223 <button type="button" class="btn btn-sm btn-secondary" data-act="tasks.openStatusTokens" data-a1="${escAttr(taskId)}" title="Link commits to this task">Commits</button>
224 </div>
225 <div class="form-actions" style="margin-top: 0.75rem;">
226 <button type="button" class="btn btn-secondary" data-act="tasks.openActions" data-a1="${escAttr(taskId)}">All Actions</button>
227 <div class="flex-1"></div>
228 <button type="button" class="btn btn-secondary" data-act="tasks.closeSubtasksAndRefresh">Close</button>
229 </div>
230 `;
231 GoingsOn.ui.openModal('Manage Subtasks', content);
232 }
233
234 /**
235 * Render and open the status-tokens (commits) manager for a task.
236 * Lists linked commits with a pushed toggle, a primary toggle, and a remove
237 * button, plus an add form. Colours: amber = associated but not pushed,
238 * green = pushed.
239 * @param {string} taskId - Parent task ID
240 * @param {Array<Object>} tokens - Array of status-token objects
241 */
242 function renderStatusTokensModal(taskId, tokens) {
243 const list = (tokens || []).length === 0
244 ? '<p class="subtasks-empty">No commits linked yet. Add one below.</p>'
245 : tokens.map(k => {
246 const pushed = k.state === 'Complete';
247 const primary = !!k.isPrimary;
248 const ref = k.reference;
249 const p = pushed ? '1' : '0';
250 const pr = primary ? '1' : '0';
251 const nextPushed = pushed ? '0' : '1';
252 const nextPrimary = primary ? '0' : '1';
253 return `
254 <div class="token-item">
255 <span class="token-dot token-dot--${pushed ? 'complete' : 'pending'}" aria-hidden="true"></span>
256 <span class="token-ref flex-1${primary ? ' token-ref--primary' : ''}" title="${escAttr(k.kind)}">${esc(ref)}${primary ? ' <span class="token-primary-tag">primary</span>' : ''}</span>
257 <button class="btn btn-sm" data-act="tasks.setStatusToken" data-a1="${escAttr(taskId)}" data-a2="${escAttr(k.kind)}" data-a3="${escAttr(ref)}" data-a4="${nextPushed}" data-a5="${pr}" title="${pushed ? 'Mark as not pushed' : 'Mark as pushed'}">${pushed ? 'Pushed' : 'Unpushed'}</button>
258 <button class="btn btn-sm" data-act="tasks.setStatusToken" data-a1="${escAttr(taskId)}" data-a2="${escAttr(k.kind)}" data-a3="${escAttr(ref)}" data-a4="${p}" data-a5="${nextPrimary}" title="${primary ? 'Unset as the resolving commit' : 'Set as the resolving commit'}">${primary ? 'Primary' : 'Set primary'}</button>
259 <button class="btn btn-sm text-accent-red" data-act="tasks.deleteStatusToken" data-a1="${escAttr(taskId)}" data-a2="${escAttr(k.id)}" aria-label="Remove commit">&times;</button>
260 </div>
261 `;
262 }).join('');
263
264 const content = `
265 <p class="text-sm-secondary mb-1">Link the commit(s) that advanced this task. Amber = associated but not pushed; green = pushed. Mark the one that resolved it as primary.</p>
266 <div style="margin-bottom: 1rem; max-height: 300px; overflow-y: auto;">
267 ${list}
268 </div>
269 <form data-submit="tasks.addStatusToken" data-a1="@event" data-a2="${escAttr(taskId)}" class="row-flex row-flex-2">
270 <input type="text" class="form-input flex-1" name="commit_ref" placeholder="repo@shortsha, e.g. deox@7c236fca8">
271 <button type="submit" class="btn btn-primary">Add</button>
272 </form>
273 <div class="form-actions" style="margin-top: 0.75rem;">
274 <div class="flex-1"></div>
275 <button type="button" class="btn btn-secondary" data-act="tasks.closeSubtasksAndRefresh">Close</button>
276 </div>
277 `;
278 GoingsOn.ui.openModal('Commits', content);
279 }
280
281 // ============ Populate GoingsOn.tasksRender Namespace ============
282
283 GoingsOn.tasksRender = {
284 renderTaskBadges,
285 renderTokenDot,
286 renderTaskRow,
287 renderSubtasksModal,
288 renderStatusTokensModal,
289 formatRecurrence,
290 };
291
292 })();
293