Skip to main content

max / goingson

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