/** * GoingsOn - Task Overview Module * Full detail view for a task: metadata, subtasks, time sessions, annotations. * For recurring tasks: completion heatmap and streak stats. */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; let currentTaskId = null; let heatmapMonth = null; // Date object for displayed month // ============ Open / Close ============ /** * Phase 7 Tier 6 — task detail now opens as a right-side drawer overlay * on top of whichever view the user is currently in (task list, contact * dashboard, day plan...). The drawer reuses the same render() output as * the old full-page #task-overview-view, which is retained for cases * where something explicitly navigates to it (router deep links). */ async function open(taskId) { currentTaskId = taskId; heatmapMonth = new Date(); const drawer = document.getElementById('task-detail-drawer'); const content = document.getElementById('task-drawer-content'); if (!drawer || !content) { // Drawer not mounted (older index.html?) — fall back to legacy // full-page view so we never break the flow. return openLegacy(taskId); } drawer.classList.add('visible'); drawer.setAttribute('aria-hidden', 'false'); document.addEventListener('keydown', handleDrawerKeydown); content.innerHTML = '
Loading...
'; markActiveRow(taskId); try { const data = await GoingsOn.api.tasks.getOverview(taskId); render(data); } catch (err) { content.innerHTML = `

Failed to load task: ${esc(String(err))}

`; } } /** * Legacy fallback: navigate to the full-page #task-overview-view. Used * only when the drawer element isn't in the DOM. Keeps deep-link routes * working through the transition. */ async function openLegacy(taskId) { GoingsOn.navigation.switchView('task-overview'); const content = document.getElementById('task-overview-content'); content.innerHTML = '
Loading...
'; try { const data = await GoingsOn.api.tasks.getOverview(taskId); renderLegacy(data); } catch (err) { content.innerHTML = `

Failed to load task: ${esc(String(err))}

`; } } function close() { currentTaskId = null; const drawer = document.getElementById('task-detail-drawer'); if (drawer && drawer.classList.contains('visible')) { drawer.classList.remove('visible'); drawer.setAttribute('aria-hidden', 'true'); document.removeEventListener('keydown', handleDrawerKeydown); clearActiveRow(); return; } // Legacy full-page mode: navigate back to the task list. GoingsOn.navigation.switchView('tasks'); } // ============ Drawer interaction ============ function handleDrawerKeydown(e) { if (e.key === 'Escape') { close(); return; } // J / K and ArrowDown / ArrowUp cycle through visible tasks. Skip // when the user is typing into an input within the drawer. const isTyping = ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName) || e.target.isContentEditable; if (isTyping) return; const dir = (e.key === 'j' || e.key === 'J' || e.key === 'ArrowDown') ? 1 : (e.key === 'k' || e.key === 'K' || e.key === 'ArrowUp') ? -1 : 0; if (dir === 0) return; e.preventDefault(); cycleToSibling(dir); } /** Move the drawer to the next/previous visible task row in the list. */ function cycleToSibling(dir) { const rows = Array.from(document.querySelectorAll('.task-row[data-id]')); if (rows.length === 0) return; const currentIdx = rows.findIndex(r => r.dataset.id === currentTaskId); let nextIdx = currentIdx + dir; if (currentIdx === -1) nextIdx = dir > 0 ? 0 : rows.length - 1; if (nextIdx < 0 || nextIdx >= rows.length) return; // stop at edges const nextId = rows[nextIdx].dataset.id; if (nextId) open(nextId); } function markActiveRow(taskId) { clearActiveRow(); document.querySelectorAll(`.task-row[data-id="${CSS.escape(taskId)}"]`).forEach(el => { el.classList.add('task-row--active'); }); } function clearActiveRow() { document.querySelectorAll('.task-row--active').forEach(el => { el.classList.remove('task-row--active'); }); } // ============ Main Render ============ /** Render task detail into the drawer (Tier 6 default surface). */ function render(data) { const t = data.task; const content = document.getElementById('task-drawer-content'); const title = document.getElementById('task-drawer-title'); const actions = document.getElementById('task-drawer-actions'); if (!content) { renderLegacy(data); return; } title.textContent = t.description; content.innerHTML = buildOverviewHtml(data); if (actions) actions.innerHTML = renderActions(t); } /** Render into the legacy full-page #task-overview-view. */ function renderLegacy(data) { const t = data.task; const content = document.getElementById('task-overview-content'); const title = document.getElementById('task-overview-title'); if (title) title.textContent = t.description; content.innerHTML = buildOverviewHtml(data); const actions = document.getElementById('task-overview-actions'); if (actions) actions.innerHTML = renderActions(t); } /** Build the body HTML once; drawer and legacy view share output. */ function buildOverviewHtml(data) { const t = data.task; let html = ''; if (data.recurrenceChain.length > 0 && data.streak) { html += renderHabitSection(data); } html += renderMetadata(t); if (t.subtasks.length > 0 || t.status !== 'Completed') { html += renderSubtasks(t); } html += renderTimeTracking(t, data.timeSessions); html += renderAnnotations(t); return html; } // ============ Habit / Recurrence Section ============ function renderHabitSection(data) { const s = data.streak; let html = '
'; html += '

Completion History

'; // Streak stats html += '
'; html += renderStat('Current Streak', s.currentStreak + 'd'); html += renderStat('Best Streak', s.bestStreak + 'd'); html += renderStat('Completion Rate', Math.round(s.completionRate30d) + '%'); html += renderStat('Total Completed', s.totalCompleted + '/' + s.totalInstances); html += '
'; // Heatmap html += '
'; html += ``; html += `${formatMonthLabel(heatmapMonth)}`; html += ``; html += '
'; html += `
${renderHeatmap(data.recurrenceChain)}
`; // Recent completions list const completed = data.recurrenceChain .filter(i => i.completedAt) .slice(0, 10); if (completed.length > 0) { html += '
'; for (const inst of completed) { const date = new Date(inst.completedAt); const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const time = inst.actualMinutes > 0 ? ` (${inst.actualMinutes}m tracked)` : ''; html += `
${esc(dateStr)} — completed${esc(time)}
`; } html += '
'; } html += '
'; return html; } function renderStat(label, value) { return `
${esc(String(value))}
${esc(label)}
`; } // ============ Heatmap ============ function renderHeatmap(chain) { const year = heatmapMonth.getFullYear(); const month = heatmapMonth.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const firstDay = new Date(year, month, 1); // Monday = 0, Sunday = 6 const firstDayOffset = (firstDay.getDay() + 6) % 7; // Build completion map: date string -> count const completionMap = {}; for (const inst of chain) { if (inst.completedAt) { const d = new Date(inst.completedAt); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; completionMap[key] = (completionMap[key] || 0) + 1; } } const today = new Date(); const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; const dayHeaders = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; let html = '
'; html += '
'; for (const d of dayHeaders) { html += `
${d}
`; } html += '
'; for (let i = 0; i < firstDayOffset; i++) { html += '
'; } for (let day = 1; day <= daysInMonth; day++) { const dateKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const count = completionMap[dateKey] || 0; const intensity = count >= 3 ? 3 : count; const isToday = dateKey === todayStr; const isPast = new Date(year, month, day) < new Date(today.getFullYear(), today.getMonth(), today.getDate()); const classes = ['month-heatmap-cell']; if (isToday) classes.push('today'); if (isPast) classes.push('past'); classes.push(`intensity-${intensity}`); html += `
`; html += `${day}`; if (count > 0) { html += `
${count}
`; } html += '
'; } const totalCells = firstDayOffset + daysInMonth; const remainder = totalCells % 7; if (remainder > 0) { for (let i = 0; i < 7 - remainder; i++) { html += '
'; } } html += '
'; return html; } function prevMonth() { heatmapMonth.setMonth(heatmapMonth.getMonth() - 1); refreshHeatmap(); } function nextMonth() { heatmapMonth.setMonth(heatmapMonth.getMonth() + 1); refreshHeatmap(); } async function refreshHeatmap() { const label = document.getElementById('task-heatmap-month-label'); if (label) label.textContent = formatMonthLabel(heatmapMonth); if (!currentTaskId) return; try { const data = await GoingsOn.api.tasks.getOverview(currentTaskId); const container = document.getElementById('task-heatmap-container'); if (container) container.innerHTML = renderHeatmap(data.recurrenceChain); } catch (err) { console.error('Failed to refresh heatmap:', err); } } function formatMonthLabel(date) { return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); } // ============ Metadata ============ function renderMetadata(t) { let html = '
'; html += '
'; const STATUS_COLOR = { completed: 'green', started: 'blue', pending: 'muted' }; const PRIORITY_COLOR = { h: 'red', m: 'yellow', l: 'muted' }; const chip = (color, text) => `${esc(text)}`; const badges = []; badges.push(chip(STATUS_COLOR[t.status.toLowerCase()] || 'muted', t.status)); badges.push(chip(PRIORITY_COLOR[t.priority.toLowerCase()] || 'muted', t.priority)); if (t.isFocus) badges.push(chip('blue', 'Focus')); if (t.isOverdue) badges.push(chip('red', 'Overdue')); if (t.isSnoozed) badges.push(chip('yellow', 'Snoozed')); html += `
${badges.join(' ')}
`; if (t.descriptionHtml) { html += `
${t.descriptionHtml}
`; } const details = []; if (t.projectName) details.push(`Project: ${esc(t.projectName)}`); if (t.dueFormatted) details.push(`Due: ${esc(t.dueFormatted)}`); if (t.recurrence && t.recurrence !== 'None') details.push(`Recurrence: ${esc(t.recurrence)}`); if (t.contactName) details.push(`Contact: ${esc(t.contactName)}`); if (t.tags && t.tags.length > 0) { details.push(`Tags: ${t.tags.map(tag => `${esc(tag)}`).join(' ')}`); } if (details.length > 0) { html += `
${details.map(d => `
${d}
`).join('')}
`; } html += '
'; return html; } // ============ Subtasks ============ function renderSubtasks(t) { const completed = t.subtasks.filter(s => s.isCompleted).length; const total = t.subtasks.length; let html = '
'; html += `

Subtasks ${completed}/${total}

`; if (total > 0) { const pct = Math.round((completed / total) * 100); html += `
`; } html += '
'; for (const s of t.subtasks) { const checked = s.isCompleted ? 'checked' : ''; html += `
`; html += ``; html += `${esc(s.text)}`; if (s.linkedTaskId) html += ' Linked'; html += '
'; } html += '
'; // Add subtask form html += `
`; html += '
'; return html; } async function toggleSubtask(subtaskId) { try { await GoingsOn.api.subtasks.toggle(currentTaskId, subtaskId); GoingsOn.cache.invalidate('tasks'); if (currentTaskId) open(currentTaskId); } catch (err) { GoingsOn.ui.showToast('Failed to toggle subtask', 'error'); } } async function addSubtask() { const input = document.getElementById('overview-new-subtask'); const text = input?.value?.trim(); if (!text) return; try { await GoingsOn.api.subtasks.add(currentTaskId, text); input.value = ''; GoingsOn.cache.invalidate('tasks'); if (currentTaskId) open(currentTaskId); } catch (err) { GoingsOn.ui.showToast('Failed to add subtask', 'error'); } } // ============ Time Tracking ============ function renderTimeTracking(t, sessions) { let html = '
'; const est = t.estimatedMinutes ? `${t.estimatedMinutes}m est` : ''; const actual = `${t.actualMinutes}m tracked`; const label = est ? `${actual} / ${est}` : actual; html += `

Time Tracking ${esc(label)}

`; if (t.estimatedMinutes && t.estimatedMinutes > 0) { const pct = Math.min(Math.round((t.actualMinutes / t.estimatedMinutes) * 100), 100); const overClass = t.isOverEstimate ? ' over-estimate' : ''; html += `
`; } if (sessions.length > 0) { html += '
'; for (const s of sessions) { const start = new Date(s.startedAt); const dateStr = start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const timeStr = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); const duration = s.durationMinutes != null ? `${s.durationMinutes}m` : 'active'; html += `
${esc(dateStr)} ${esc(timeStr)} — ${esc(duration)}
`; } html += '
'; } html += '
'; return html; } // ============ Annotations ============ function renderAnnotations(t) { let html = '
'; html += `

Notes ${t.annotations.length}

`; if (t.annotations.length > 0) { html += '
'; for (const a of t.annotations) { const date = new Date(a.timestamp); const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); html += `
`; html += `
${esc(dateStr)}
`; html += `
${esc(a.note)}
`; html += '
'; } html += '
'; } // Add note form html += `
`; html += '
'; return html; } async function addNote() { const input = document.getElementById('overview-new-note'); const text = input?.value?.trim(); if (!text) return; try { await GoingsOn.api.annotations.add(currentTaskId, text); input.value = ''; GoingsOn.cache.invalidate('tasks'); if (currentTaskId) open(currentTaskId); } catch (err) { GoingsOn.ui.showToast('Failed to add note', 'error'); } } // ============ Actions ============ function renderActions(t) { let html = ''; if (t.status !== 'Completed') { html += ` `; } html += ` `; html += ``; return html; } async function completeTask() { if (!currentTaskId) return; try { await GoingsOn.api.tasks.complete(currentTaskId); GoingsOn.cache.invalidate('tasks'); GoingsOn.ui.showToast('Task completed!', 'success'); open(currentTaskId); // Refresh to show updated state } catch (err) { GoingsOn.ui.showToast('Failed to complete task', 'error'); } } async function deleteTask() { if (!currentTaskId) return; if (!await GoingsOn.ui.confirmDelete('task')) return; try { await GoingsOn.api.tasks.delete(currentTaskId); GoingsOn.cache.invalidate('tasks'); GoingsOn.ui.showToast('Task deleted', 'success'); close(); } catch (err) { GoingsOn.ui.showToast('Failed to delete task', 'error'); } } // ============ Namespace ============ GoingsOn.taskOverview = { open, close, prevMonth, nextMonth, toggleSubtask, addSubtask, addNote, completeTask, deleteTask, }; })();