/** * @fileoverview Time tracking: floating timer widget, start/stop/discard. * * On init, checks for an active timer. When active, shows a floating bar * at the bottom with task name, elapsed time (h:mm:ss), and stop/discard buttons. * Elapsed time is computed client-side from timerStartedAt. */ (function() { 'use strict'; const esc = (s) => GoingsOn.utils.escapeHtml(s); let tickInterval = null; let activeTimer = null; // { taskId, taskDescription, startedAt } // ============ Timer Widget ============ function createWidget() { if (document.getElementById('timer-widget')) return; const widget = document.createElement('div'); widget.id = 'timer-widget'; widget.className = 'timer-widget hidden'; widget.innerHTML = `
`; document.body.appendChild(widget); widget.querySelector('.timer-stop-btn').addEventListener('click', stopActive); widget.querySelector('.timer-discard-btn').addEventListener('click', discardActive); } function showWidget(taskDescription, startedAt) { const widget = document.getElementById('timer-widget'); if (!widget) return; widget.querySelector('.timer-task-name').textContent = taskDescription; widget.classList.remove('hidden'); activeTimer = { startedAt: new Date(startedAt) }; updateElapsed(); if (tickInterval) clearInterval(tickInterval); tickInterval = setInterval(updateElapsed, 1000); } function hideWidget() { const widget = document.getElementById('timer-widget'); if (widget) widget.classList.add('hidden'); if (tickInterval) { clearInterval(tickInterval); tickInterval = null; } activeTimer = null; } function updateElapsed() { if (!activeTimer) return; const widget = document.getElementById('timer-widget'); if (!widget) return; const diff = Math.floor((Date.now() - activeTimer.startedAt.getTime()) / 1000); const h = Math.floor(diff / 3600); const m = Math.floor((diff % 3600) / 60); const s = diff % 60; const display = h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; widget.querySelector('.timer-elapsed').textContent = display; } // ============ Actions ============ /** * Start a time tracking timer for a task. Shows the floating widget. * @param {string} taskId - Task ID to track time for */ async function startTimer(taskId) { console.log('[timer] startTimer called', { taskId, hasApi: !!GoingsOn.api?.timeTracking?.startTimer }); try { const result = await GoingsOn.api.timeTracking.startTimer(taskId); console.log('[timer] startTimer succeeded', result); await checkActive(); if (GoingsOn.tasks?.load) GoingsOn.tasks.load(); } catch (err) { console.error('[timer] startTimer failed', err); GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to start timer'), 'error'); } } async function stopActive() { if (!activeTimer) return; try { const result = await GoingsOn.api.timeTracking.getActive(); if (result) { const session = await GoingsOn.api.timeTracking.stopTimer(result.taskId); if (session) { const mins = session.durationMinutes || 0; const display = mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; GoingsOn.ui.showToast(`Tracked ${display}`, 'success'); } } hideWidget(); if (GoingsOn.tasks?.load) GoingsOn.tasks.load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to stop timer'), 'error'); } } async function discardActive() { if (!activeTimer) return; try { const result = await GoingsOn.api.timeTracking.getActive(); if (result) { await GoingsOn.api.timeTracking.discardTimer(result.taskId); } hideWidget(); if (GoingsOn.tasks?.load) GoingsOn.tasks.load(); GoingsOn.ui.showToast('Timer discarded', 'info'); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to discard timer'), 'error'); } } /** * Check for an active timer session and show/hide the widget accordingly. */ async function checkActive() { try { const result = await GoingsOn.api.timeTracking.getActive(); if (result) { showWidget(result.taskDescription, result.session.startedAt); } else { hideWidget(); } } catch (err) { console.error('Failed to check active timer:', err); } } // ============ Timer Subview ============ let subviewTickInterval = null; let focusWorkMinutes = 25; let focusBreakMinutes = 5; /** * Format elapsed time since a start timestamp as h:mm:ss or m:ss. * @param {string} startedAt - ISO 8601 start timestamp * @returns {string} Formatted elapsed time */ function fmtElapsed(startedAt) { const diff = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000)); const h = Math.floor(diff / 3600); const m = Math.floor((diff % 3600) / 60); const s = diff % 60; return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; } function clearSubviewTick() { if (subviewTickInterval) { clearInterval(subviewTickInterval); subviewTickInterval = null; } } /** * Load and render the Timer sub-view with active session, focus split, and task list. */ async function loadTimerView() { const container = document.getElementById('timer-subview-content'); if (!container) return; console.log('[timer] loadTimerView start'); // Fetch data independently so one failure doesn't block the rest let activeResult = null; let tasks = []; try { activeResult = await GoingsOn.api.timeTracking.getActive(); console.log('[timer] getActive result:', activeResult); } catch (err) { console.error('[timer] getActive failed:', err); } try { const [pendingResp, startedResp] = await Promise.all([ GoingsOn.api.tasks.listFiltered({ status: 'Pending', showSnoozed: false, limit: 200 }), GoingsOn.api.tasks.listFiltered({ status: 'Started', showSnoozed: false, limit: 200 }), ]); console.log('[timer] listFiltered results — pending:', pendingResp?.tasks?.length, 'started:', startedResp?.tasks?.length); const pending = pendingResp?.tasks || []; const started = startedResp?.tasks || []; // Started first (more likely to be tracked), then pending const allTasks = [...started, ...pending]; // Remove the actively-timed task from the list const activeTaskId = activeResult?.taskId; tasks = activeTaskId ? allTasks.filter(t => t.id !== activeTaskId) : allTasks; } catch (err) { console.error('[timer] listFiltered failed:', err); } clearSubviewTick(); let html = ''; // ---- Active session banner ---- if (activeResult) { html += `
Tracking ${esc(activeResult.taskDescription)}
${fmtElapsed(activeResult.session.startedAt)}
`; const startMs = new Date(activeResult.session.startedAt).getTime(); subviewTickInterval = setInterval(() => { const el = document.getElementById('timer-subview-elapsed'); if (!el) { clearSubviewTick(); return; } const diff = Math.max(0, Math.floor((Date.now() - startMs) / 1000)); const h = Math.floor(diff / 3600); const m = Math.floor((diff % 3600) / 60); const s = diff % 60; el.textContent = h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; }, 1000); } // ---- Focus split inputs ---- html += `
Focus split: work / break
`; // ---- Task list ---- if (tasks.length === 0 && !activeResult) { html += `

No pending or started tasks to track.

`; } else if (tasks.length > 0) { const hasActive = !!activeResult; html += `
`; for (const task of tasks) { const project = task.projectName ? `${esc(task.projectName)}` : ''; const pri = task.priority ? `${esc(task.priority)}` : ''; const est = task.estimatedMinutes ? `${task.estimatedMinutes}m est` : ''; const tracked = task.actualMinutes > 0 ? `${task.actualMinutes}m tracked` : ''; const disabled = hasActive ? ' disabled' : ''; html += `
${esc(task.description)}
${project}${pri}${est}${tracked}
`; } html += `
`; } container.innerHTML = html; } function updateFocusSplit() { const workEl = document.getElementById('focus-work-minutes'); const breakEl = document.getElementById('focus-break-minutes'); if (workEl) focusWorkMinutes = Math.max(1, Math.min(240, parseInt(workEl.value, 10) || 25)); if (breakEl) focusBreakMinutes = Math.max(1, Math.min(60, parseInt(breakEl.value, 10) || 5)); // Update Focus button titles document.querySelectorAll('.timer-task-actions button:last-child').forEach(btn => { if (btn.textContent.trim() === 'Focus') { btn.title = `Start ${focusWorkMinutes}/${focusBreakMinutes} focus session`; } }); } async function trackFromTimerView(taskId) { try { await startTimer(taskId); } catch (err) { // startTimer already shows toast } await loadTimerView(); } async function focusFromTimerView(taskId) { if (GoingsOn.focusTimer?.start) { await GoingsOn.focusTimer.start(taskId, { workMinutes: focusWorkMinutes, breakMinutes: focusBreakMinutes, }); } } async function stopAndRefreshTimerView() { try { const result = await GoingsOn.api.timeTracking.getActive(); if (result) { const session = await GoingsOn.api.timeTracking.stopTimer(result.taskId); if (session) { const mins = session.durationMinutes || 0; const display = mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; GoingsOn.ui.showToast(`Tracked ${display}`, 'success'); } } hideWidget(); if (GoingsOn.tasks?.load) GoingsOn.tasks.load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to stop timer'), 'error'); } await loadTimerView(); } async function discardAndRefreshTimerView() { try { const result = await GoingsOn.api.timeTracking.getActive(); if (result) { await GoingsOn.api.timeTracking.discardTimer(result.taskId); } hideWidget(); if (GoingsOn.tasks?.load) GoingsOn.tasks.load(); GoingsOn.ui.showToast('Timer discarded', 'info'); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to discard timer'), 'error'); } await loadTimerView(); } // ============ Init ============ function init() { createWidget(); checkActive(); } // ============ Manual Time Entry ============ function openLogTimeModal(taskId) { const content = `
`; GoingsOn.ui.openModal('Log Time', content); } async function submitLogTime(e, taskId) { e.preventDefault(); const form = e.target; const minutes = parseInt(form.minutes.value, 10); const dateStr = form.date.value; if (!minutes || minutes < 1) return; // Convert date to UTC datetime (noon on selected day) const date = new Date(dateStr + 'T12:00:00Z').toISOString(); try { await GoingsOn.api.timeTracking.logManual(taskId, minutes, date); const display = minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`; GoingsOn.ui.showToast(`Logged ${display}`, 'success'); GoingsOn.ui.closeModal(); await loadTimerView(); if (GoingsOn.tasks?.load) GoingsOn.tasks.load(); } catch (err) { GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to log time'), 'error'); } } // ============ Namespace ============ GoingsOn.timeTracking = { init, startTimer, stopActive, discardActive, checkActive, loadTimerView, trackFromTimerView, focusFromTimerView, stopAndRefreshTimerView, discardAndRefreshTimerView, updateFocusSplit, openLogTimeModal, submitLogTime, }; })();