Skip to main content

max / goingson

21.6 KB · 536 lines History Blame Raw
1 /**
2 * GoingsOn - Task Overview Module
3 * Full detail view for a task: metadata, subtasks, time sessions, annotations.
4 * For recurring tasks: completion heatmap and streak stats.
5 */
6
7 (function() {
8 'use strict';
9 const esc = GoingsOn.utils.escapeHtml;
10 const escAttr = GoingsOn.utils.escapeAttrValue;
11 const escArg = GoingsOn.utils.escapeHandlerArg;
12
13 let currentTaskId = null;
14 let heatmapMonth = null; // Date object for displayed month
15 let heatmapBuckets = []; // Rust-aggregated {date, count} per local day
16
17 // ============ Open / Close ============
18
19 /**
20 * Phase 7 Tier 6 — task detail now opens as a right-side drawer overlay
21 * on top of whichever view the user is currently in (task list, contact
22 * dashboard, day plan...). The drawer reuses the same render() output as
23 * the old full-page #task-overview-view, which is retained for cases
24 * where something explicitly navigates to it (router deep links).
25 */
26 async function open(taskId) {
27 currentTaskId = taskId;
28 heatmapMonth = new Date();
29
30 const drawer = document.getElementById('task-detail-drawer');
31 const content = document.getElementById('task-drawer-content');
32 if (!drawer || !content) {
33 // Drawer not mounted (older index.html?) — fall back to legacy
34 // full-page view so we never break the flow.
35 return openLegacy(taskId);
36 }
37
38 drawer.classList.add('visible');
39 drawer.setAttribute('aria-hidden', 'false');
40 document.addEventListener('keydown', handleDrawerKeydown);
41
42 content.innerHTML = '<div class="loading">Loading...</div>';
43 markActiveRow(taskId);
44
45 try {
46 const data = await GoingsOn.api.tasks.getOverview(taskId);
47 render(data);
48 } catch (err) {
49 content.innerHTML = `<div class="empty-state"><p class="empty-state-text">Failed to load task: ${esc(GoingsOn.utils.getErrorMessage(err, ""))}</p></div>`;
50 }
51 }
52
53 /**
54 * Legacy fallback: navigate to the full-page #task-overview-view. Used
55 * only when the drawer element isn't in the DOM. Keeps deep-link routes
56 * working through the transition.
57 */
58 async function openLegacy(taskId) {
59 GoingsOn.navigation.switchView('task-overview');
60
61 const content = document.getElementById('task-overview-content');
62 content.innerHTML = '<div class="loading">Loading...</div>';
63
64 try {
65 const data = await GoingsOn.api.tasks.getOverview(taskId);
66 renderLegacy(data);
67 } catch (err) {
68 content.innerHTML = `<div class="empty-state"><p class="empty-state-text">Failed to load task: ${esc(GoingsOn.utils.getErrorMessage(err, ""))}</p></div>`;
69 }
70 }
71
72 function close() {
73 currentTaskId = null;
74 const drawer = document.getElementById('task-detail-drawer');
75 if (drawer && drawer.classList.contains('visible')) {
76 drawer.classList.remove('visible');
77 drawer.setAttribute('aria-hidden', 'true');
78 document.removeEventListener('keydown', handleDrawerKeydown);
79 clearActiveRow();
80 return;
81 }
82 // Legacy full-page mode: navigate back to the task list.
83 GoingsOn.navigation.switchView('tasks');
84 }
85
86 // ============ Drawer interaction ============
87
88 function handleDrawerKeydown(e) {
89 if (e.key === 'Escape') {
90 close();
91 return;
92 }
93 // J / K and ArrowDown / ArrowUp cycle through visible tasks. Skip
94 // when the user is typing into an input within the drawer.
95 const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)
96 || e.target.isContentEditable;
97 if (isTyping) return;
98
99 const dir = (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') ? 1
100 : (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') ? -1
101 : 0;
102 if (dir === 0) return;
103 e.preventDefault();
104 cycleToSibling(dir);
105 }
106
107 /** Move the drawer to the next/previous visible task row in the list. */
108 function cycleToSibling(dir) {
109 const rows = Array.from(document.querySelectorAll('.task-row[data-id]'));
110 if (rows.length === 0) return;
111 const currentIdx = rows.findIndex(r => r.dataset.id === currentTaskId);
112 let nextIdx = currentIdx + dir;
113 if (currentIdx === -1) nextIdx = dir > 0 ? 0 : rows.length - 1;
114 if (nextIdx < 0 || nextIdx >= rows.length) return; // stop at edges
115 const nextId = rows[nextIdx].dataset.id;
116 if (nextId) open(nextId);
117 }
118
119 function markActiveRow(taskId) {
120 clearActiveRow();
121 document.querySelectorAll(`.task-row[data-id="${CSS.escape(taskId)}"]`).forEach(el => {
122 el.classList.add('task-row--active');
123 });
124 }
125
126 function clearActiveRow() {
127 document.querySelectorAll('.task-row--active').forEach(el => {
128 el.classList.remove('task-row--active');
129 });
130 }
131
132 // ============ Main Render ============
133
134 /** Render task detail into the drawer (Tier 6 default surface). */
135 function render(data) {
136 const t = data.task;
137 const content = document.getElementById('task-drawer-content');
138 const title = document.getElementById('task-drawer-title');
139 const actions = document.getElementById('task-drawer-actions');
140 if (!content) {
141 renderLegacy(data);
142 return;
143 }
144 title.textContent = t.description;
145 content.innerHTML = buildOverviewHtml(data);
146 if (actions) actions.innerHTML = renderActions(t);
147 }
148
149 /** Render into the legacy full-page #task-overview-view. */
150 function renderLegacy(data) {
151 const t = data.task;
152 const content = document.getElementById('task-overview-content');
153 const title = document.getElementById('task-overview-title');
154 if (title) title.textContent = t.description;
155 content.innerHTML = buildOverviewHtml(data);
156 const actions = document.getElementById('task-overview-actions');
157 if (actions) actions.innerHTML = renderActions(t);
158 }
159
160 /** Build the body HTML once; drawer and legacy view share output. */
161 function buildOverviewHtml(data) {
162 const t = data.task;
163 heatmapBuckets = data.completionBuckets || [];
164 let html = '';
165 if (data.recurrenceChain.length > 0 && data.streak) {
166 html += renderHabitSection(data);
167 }
168 html += renderMetadata(t);
169 if (t.subtasks.length > 0 || t.status !== 'Completed') {
170 html += renderSubtasks(t);
171 }
172 html += renderTimeTracking(t, data.timeSessions);
173 html += renderAnnotations(t);
174 return html;
175 }
176
177 // ============ Habit / Recurrence Section ============
178
179 function renderHabitSection(data) {
180 const s = data.streak;
181 let html = '<div class="task-overview-section">';
182 html += '<h3 class="task-overview-section-title">Completion History</h3>';
183
184 // Streak stats
185 html += '<div class="task-overview-stats">';
186 html += renderStat('Current Streak', s.currentStreak + 'd');
187 html += renderStat('Best Streak', s.bestStreak + 'd');
188 html += renderStat('Completion Rate', Math.round(s.completionRate30d) + '%');
189 html += renderStat('Total Completed', s.totalCompleted + '/' + s.totalInstances);
190 html += '</div>';
191
192 // Heatmap
193 html += '<div class="task-overview-heatmap-nav">';
194 html += `<button class="btn btn-sm btn-secondary" data-act="taskOverview.prevMonth">&#9664;</button>`;
195 html += `<span id="task-heatmap-month-label">${formatMonthLabel(heatmapMonth)}</span>`;
196 html += `<button class="btn btn-sm btn-secondary" data-act="taskOverview.nextMonth">&#9654;</button>`;
197 html += '</div>';
198 html += `<div id="task-heatmap-container">${renderHeatmap()}</div>`;
199
200 // Recent completions list
201 const completed = data.recurrenceChain
202 .filter(i => i.completedAt)
203 .slice(0, 10);
204 if (completed.length > 0) {
205 html += '<div class="task-overview-completion-list">';
206 for (const inst of completed) {
207 const date = new Date(inst.completedAt);
208 const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
209 const time = inst.actualMinutes > 0 ? ` (${inst.actualMinutes}m tracked)` : '';
210 html += `<div class="task-overview-completion-item">${esc(dateStr)} — completed${esc(time)}</div>`;
211 }
212 html += '</div>';
213 }
214
215 html += '</div>';
216 return html;
217 }
218
219 function renderStat(label, value) {
220 return `<div class="task-overview-stat"><div class="task-overview-stat-value">${esc(String(value))}</div><div class="task-overview-stat-label">${esc(label)}</div></div>`;
221 }
222
223 // ============ Heatmap ============
224
225 function renderHeatmap() {
226 const year = heatmapMonth.getFullYear();
227 const month = heatmapMonth.getMonth();
228 const daysInMonth = new Date(year, month + 1, 0).getDate();
229 const firstDay = new Date(year, month, 1);
230 // Monday = 0, Sunday = 6
231 const firstDayOffset = (firstDay.getDay() + 6) % 7;
232
233 // Completion counts per local day are pre-aggregated by Rust
234 // (get_task_overview -> completionBuckets); JS only lays out the grid.
235 const completionMap = {};
236 for (const b of heatmapBuckets) {
237 completionMap[b.date] = b.count;
238 }
239
240 const today = new Date();
241 const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
242
243 const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
244 let html = '<div class="month-heatmap">';
245 html += '<div class="month-heatmap-header">';
246 for (const d of dayHeaders) {
247 html += `<div class="month-heatmap-day-header">${d}</div>`;
248 }
249 html += '</div><div class="month-heatmap-grid">';
250
251 for (let i = 0; i < firstDayOffset; i++) {
252 html += '<div class="month-heatmap-cell empty"></div>';
253 }
254
255 for (let day = 1; day <= daysInMonth; day++) {
256 const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
257 const count = completionMap[dateKey] || 0;
258 const intensity = count >= 3 ? 3 : count;
259 const isToday = dateKey === todayStr;
260 const isPast = new Date(year, month, day) < new Date(today.getFullYear(), today.getMonth(), today.getDate());
261
262 const classes = ['month-heatmap-cell'];
263 if (isToday) classes.push('today');
264 if (isPast) classes.push('past');
265 classes.push(`intensity-${intensity}`);
266
267 html += `<div class="${classes.join(' ')}" title="${count} completion${count !== 1 ? 's' : ''}" tabindex="0">`;
268 html += `<span class="month-heatmap-day-number">${day}</span>`;
269 if (count > 0) {
270 html += `<div class="month-heatmap-dots"><span class="month-dot completed">${count}</span></div>`;
271 }
272 html += '</div>';
273 }
274
275 const totalCells = firstDayOffset + daysInMonth;
276 const remainder = totalCells % 7;
277 if (remainder > 0) {
278 for (let i = 0; i < 7 - remainder; i++) {
279 html += '<div class="month-heatmap-cell empty"></div>';
280 }
281 }
282
283 html += '</div></div>';
284 return html;
285 }
286
287 function prevMonth() {
288 heatmapMonth.setMonth(heatmapMonth.getMonth() - 1);
289 refreshHeatmap();
290 }
291
292 function nextMonth() {
293 heatmapMonth.setMonth(heatmapMonth.getMonth() + 1);
294 refreshHeatmap();
295 }
296
297 function refreshHeatmap() {
298 const label = document.getElementById('task-heatmap-month-label');
299 if (label) label.textContent = formatMonthLabel(heatmapMonth);
300
301 // Buckets cover the whole chain, so month navigation just re-lays out
302 // the cached data — no round-trip to Rust.
303 const container = document.getElementById('task-heatmap-container');
304 if (container) container.innerHTML = renderHeatmap();
305 }
306
307 function formatMonthLabel(date) {
308 return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
309 }
310
311 // ============ Metadata ============
312
313 function renderMetadata(t) {
314 let html = '<div class="task-overview-section">';
315 html += '<div class="task-overview-meta">';
316
317 const STATUS_COLOR = { completed: 'green', started: 'blue', pending: 'muted' };
318 const PRIORITY_COLOR = { h: 'red', m: 'yellow', l: 'muted' };
319 const chip = (color, text) => `<span class="badge badge--xs badge--filled" data-color="${color}">${esc(text)}</span>`;
320 const badges = [];
321 badges.push(chip(STATUS_COLOR[t.status.toLowerCase()] || 'muted', t.status));
322 badges.push(chip(PRIORITY_COLOR[t.priority.toLowerCase()] || 'muted', t.priority));
323 if (t.isFocus) badges.push(chip('blue', 'Focus'));
324 if (t.isOverdue) badges.push(chip('red', 'Overdue'));
325 if (t.isSnoozed) badges.push(chip('yellow', 'Snoozed'));
326 html += `<div class="task-overview-badges">${badges.join(' ')}</div>`;
327
328 if (t.descriptionHtml) {
329 html += `<div class="markdown-content">${t.descriptionHtml}</div>`;
330 }
331
332 const details = [];
333 if (t.projectName) details.push(`<strong>Project:</strong> ${esc(t.projectName)}`);
334 if (t.dueFormatted) details.push(`<strong>Due:</strong> ${esc(t.dueFormatted)}`);
335 if (t.recurrence && t.recurrence !== 'None') details.push(`<strong>Recurrence:</strong> ${esc(t.recurrence)}`);
336 if (t.contactName) details.push(`<strong>Contact:</strong> ${esc(t.contactName)}`);
337 if (t.tags && t.tags.length > 0) {
338 details.push(`<strong>Tags:</strong> ${t.tags.map(tag => `<span class="tag">${esc(tag)}</span>`).join(' ')}`);
339 }
340
341 if (details.length > 0) {
342 html += `<div class="task-overview-details">${details.map(d => `<div>${d}</div>`).join('')}</div>`;
343 }
344
345 html += '</div></div>';
346 return html;
347 }
348
349 // ============ Subtasks ============
350
351 function renderSubtasks(t) {
352 const completed = t.subtasks.filter(s => s.isCompleted).length;
353 const total = t.subtasks.length;
354
355 let html = '<div class="task-overview-section">';
356 html += `<h3 class="task-overview-section-title">Subtasks <span class="task-overview-count">${completed}/${total}</span></h3>`;
357
358 if (total > 0) {
359 const pct = t.subtaskProgress ?? 0; // pre-computed in Rust (TaskResponse.subtaskProgress)
360 html += `<div class="progress-bar"><div class="progress-fill" style="width: ${pct}%"></div></div>`;
361 }
362
363 html += '<div class="task-overview-subtask-list">';
364 for (const s of t.subtasks) {
365 const checked = s.isCompleted ? 'checked' : '';
366 html += `<div class="task-overview-subtask">`;
367 html += `<input type="checkbox" class="bulk-checkbox" ${checked} data-change="taskOverview.toggleSubtask" data-a1="${escAttr(s.id)}" ${s.linkedTaskId ? 'disabled' : ''}>`;
368 html += `<span class="${s.isCompleted ? 'completed-text' : ''}">${esc(s.text)}</span>`;
369 if (s.linkedTaskId) html += ' <span class="badge">Linked</span>';
370 html += '</div>';
371 }
372 html += '</div>';
373
374 // Add subtask form
375 html += `<div class="task-overview-add-form">
376 <input type="text" class="form-input" id="overview-new-subtask" placeholder="Add subtask..." data-keydown="ui.onEnter" data-a1="@event" data-a2="taskOverview.addSubtask">
377 <button class="btn btn-sm btn-primary" data-act="taskOverview.addSubtask">Add</button>
378 </div>`;
379
380 html += '</div>';
381 return html;
382 }
383
384 async function toggleSubtask(subtaskId) {
385 try {
386 await GoingsOn.api.subtasks.toggle(currentTaskId, subtaskId);
387 GoingsOn.cache.invalidate('tasks');
388 if (currentTaskId) open(currentTaskId);
389 } catch (err) {
390 GoingsOn.ui.showToast('Failed to toggle subtask', 'error');
391 }
392 }
393
394 async function addSubtask() {
395 const input = document.getElementById('overview-new-subtask');
396 const text = input?.value?.trim();
397 if (!text) return;
398
399 try {
400 await GoingsOn.api.subtasks.add(currentTaskId, text);
401 input.value = '';
402 GoingsOn.cache.invalidate('tasks');
403 if (currentTaskId) open(currentTaskId);
404 } catch (err) {
405 GoingsOn.ui.showToast('Failed to add subtask', 'error');
406 }
407 }
408
409 // ============ Time Tracking ============
410
411 function renderTimeTracking(t, sessions) {
412 let html = '<div class="task-overview-section">';
413 const est = t.estimatedMinutes ? `${t.estimatedMinutes}m est` : '';
414 const actual = `${t.actualMinutes}m tracked`;
415 const label = est ? `${actual} / ${est}` : actual;
416 html += `<h3 class="task-overview-section-title">Time Tracking <span class="task-overview-count">${esc(label)}</span></h3>`;
417
418 if (t.estimatedMinutes && t.estimatedMinutes > 0) {
419 const pct = t.timeProgress ?? 0; // pre-computed in Rust (TaskResponse.timeProgress)
420 const overClass = t.isOverEstimate ? ' over-estimate' : '';
421 html += `<div class="progress-bar${overClass}"><div class="progress-fill" style="width: ${pct}%"></div></div>`;
422 }
423
424 if (sessions.length > 0) {
425 html += '<div class="task-overview-sessions">';
426 for (const s of sessions) {
427 const start = new Date(s.startedAt);
428 const dateStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
429 const timeStr = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
430 const duration = s.durationMinutes != null ? `${s.durationMinutes}m` : 'active';
431 html += `<div class="task-overview-session">${esc(dateStr)} ${esc(timeStr)}${esc(duration)}</div>`;
432 }
433 html += '</div>';
434 }
435
436 html += '</div>';
437 return html;
438 }
439
440 // ============ Annotations ============
441
442 function renderAnnotations(t) {
443 let html = '<div class="task-overview-section">';
444 html += `<h3 class="task-overview-section-title">Notes <span class="task-overview-count">${t.annotations.length}</span></h3>`;
445
446 if (t.annotations.length > 0) {
447 html += '<div class="task-overview-annotations">';
448 for (const a of t.annotations) {
449 const date = new Date(a.timestamp);
450 const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
451 html += `<div class="task-overview-annotation">`;
452 html += `<div class="task-overview-annotation-date">${esc(dateStr)}</div>`;
453 html += `<div class="task-overview-annotation-text">${esc(a.note)}</div>`;
454 html += '</div>';
455 }
456 html += '</div>';
457 }
458
459 // Add note form
460 html += `<div class="task-overview-add-form">
461 <input type="text" class="form-input" id="overview-new-note" placeholder="Add note..." data-keydown="ui.onEnter" data-a1="@event" data-a2="taskOverview.addNote">
462 <button class="btn btn-sm btn-primary" data-act="taskOverview.addNote">Add</button>
463 </div>`;
464
465 html += '</div>';
466 return html;
467 }
468
469 async function addNote() {
470 const input = document.getElementById('overview-new-note');
471 const text = input?.value?.trim();
472 if (!text) return;
473
474 try {
475 await GoingsOn.api.annotations.add(currentTaskId, text);
476 input.value = '';
477 GoingsOn.cache.invalidate('tasks');
478 if (currentTaskId) open(currentTaskId);
479 } catch (err) {
480 GoingsOn.ui.showToast('Failed to add note', 'error');
481 }
482 }
483
484 // ============ Actions ============
485
486 function renderActions(t) {
487 let html = '';
488 if (t.status !== 'Completed') {
489 html += `<button class="btn btn-primary" data-act="taskOverview.completeTask">Complete</button> `;
490 }
491 html += `<button class="btn btn-secondary" data-act="tasks.openEdit" data-a1="${escAttr(t.id)}">Edit</button> `;
492 html += `<button class="btn btn-secondary text-accent-red" data-act="taskOverview.deleteTask">Delete</button>`;
493 return html;
494 }
495
496 async function completeTask() {
497 if (!currentTaskId) return;
498 try {
499 await GoingsOn.api.tasks.complete(currentTaskId);
500 GoingsOn.cache.invalidate('tasks');
501 GoingsOn.ui.showToast('Task completed!', 'success');
502 open(currentTaskId); // Refresh to show updated state
503 } catch (err) {
504 GoingsOn.ui.showToast('Failed to complete task', 'error');
505 }
506 }
507
508 async function deleteTask() {
509 if (!currentTaskId) return;
510 if (!await GoingsOn.ui.confirmDelete('task')) return;
511 try {
512 await GoingsOn.api.tasks.delete(currentTaskId);
513 GoingsOn.cache.invalidate('tasks');
514 GoingsOn.ui.showToast('Task deleted', 'success');
515 close();
516 } catch (err) {
517 GoingsOn.ui.showToast('Failed to delete task', 'error');
518 }
519 }
520
521 // ============ Namespace ============
522
523 GoingsOn.taskOverview = {
524 open,
525 close,
526 prevMonth,
527 nextMonth,
528 toggleSubtask,
529 addSubtask,
530 addNote,
531 completeTask,
532 deleteTask,
533 };
534
535 })();
536