Skip to main content

max / goingson

16.2 KB · 424 lines History Blame Raw
1 /**
2 * @fileoverview Time tracking: floating timer widget, start/stop/discard.
3 *
4 * On init, checks for an active timer. When active, shows a floating bar
5 * at the bottom with task name, elapsed time (h:mm:ss), and stop/discard buttons.
6 * Elapsed time is computed client-side from timerStartedAt.
7 */
8 (function() {
9 'use strict';
10
11 const esc = (s) => GoingsOn.utils.escapeHtml(s);
12
13 let tickInterval = null;
14 let activeTimer = null; // { taskId, taskDescription, startedAt }
15
16 // ============ Timer Widget ============
17
18 function createWidget() {
19 if (document.getElementById('timer-widget')) return;
20 const widget = document.createElement('div');
21 widget.id = 'timer-widget';
22 widget.className = 'timer-widget hidden';
23 widget.innerHTML = `
24 <div class="timer-widget-inner">
25 <span class="timer-task-name"></span>
26 <span class="timer-elapsed"></span>
27 <div class="timer-actions">
28 <button class="btn btn-sm btn-primary timer-stop-btn" title="Stop timer">Stop</button>
29 <button class="btn btn-sm btn-ghost timer-discard-btn" title="Discard timer">Discard</button>
30 </div>
31 </div>
32 `;
33 document.body.appendChild(widget);
34
35 widget.querySelector('.timer-stop-btn').addEventListener('click', stopActive);
36 widget.querySelector('.timer-discard-btn').addEventListener('click', discardActive);
37 }
38
39 function showWidget(taskDescription, startedAt) {
40 const widget = document.getElementById('timer-widget');
41 if (!widget) return;
42 widget.querySelector('.timer-task-name').textContent = taskDescription;
43 widget.classList.remove('hidden');
44 activeTimer = { startedAt: new Date(startedAt) };
45 updateElapsed();
46 if (tickInterval) clearInterval(tickInterval);
47 tickInterval = setInterval(updateElapsed, 1000);
48 }
49
50 function hideWidget() {
51 const widget = document.getElementById('timer-widget');
52 if (widget) widget.classList.add('hidden');
53 if (tickInterval) {
54 clearInterval(tickInterval);
55 tickInterval = null;
56 }
57 activeTimer = null;
58 }
59
60 function updateElapsed() {
61 if (!activeTimer) return;
62 const widget = document.getElementById('timer-widget');
63 if (!widget) return;
64 const diff = Math.floor((Date.now() - activeTimer.startedAt.getTime()) / 1000);
65 const h = Math.floor(diff / 3600);
66 const m = Math.floor((diff % 3600) / 60);
67 const s = diff % 60;
68 const display = h > 0
69 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
70 : `${m}:${String(s).padStart(2, '0')}`;
71 widget.querySelector('.timer-elapsed').textContent = display;
72 }
73
74 // ============ Actions ============
75
76 /**
77 * Start a time tracking timer for a task. Shows the floating widget.
78 * @param {string} taskId - Task ID to track time for
79 */
80 async function startTimer(taskId) {
81 console.log('[timer] startTimer called', { taskId, hasApi: !!GoingsOn.api?.timeTracking?.startTimer });
82 try {
83 const result = await GoingsOn.api.timeTracking.startTimer(taskId);
84 console.log('[timer] startTimer succeeded', result);
85 await checkActive();
86 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
87 } catch (err) {
88 console.error('[timer] startTimer failed', err);
89 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to start timer'), 'error');
90 }
91 }
92
93 async function stopActive() {
94 if (!activeTimer) return;
95 try {
96 const result = await GoingsOn.api.timeTracking.getActive();
97 if (result) {
98 const session = await GoingsOn.api.timeTracking.stopTimer(result.taskId);
99 if (session) {
100 const mins = session.durationMinutes || 0;
101 const display = mins >= 60
102 ? `${Math.floor(mins / 60)}h ${mins % 60}m`
103 : `${mins}m`;
104 GoingsOn.ui.showToast(`Tracked ${display}`, 'success');
105 }
106 }
107 hideWidget();
108 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
109 } catch (err) {
110 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to stop timer'), 'error');
111 }
112 }
113
114 async function discardActive() {
115 if (!activeTimer) return;
116 try {
117 const result = await GoingsOn.api.timeTracking.getActive();
118 if (result) {
119 await GoingsOn.api.timeTracking.discardTimer(result.taskId);
120 }
121 hideWidget();
122 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
123 GoingsOn.ui.showToast('Timer discarded', 'info');
124 } catch (err) {
125 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to discard timer'), 'error');
126 }
127 }
128
129 /**
130 * Check for an active timer session and show/hide the widget accordingly.
131 */
132 async function checkActive() {
133 try {
134 const result = await GoingsOn.api.timeTracking.getActive();
135 if (result) {
136 showWidget(result.taskDescription, result.session.startedAt);
137 } else {
138 hideWidget();
139 }
140 } catch (err) {
141 console.error('Failed to check active timer:', err);
142 }
143 }
144
145 // ============ Timer Subview ============
146
147 let subviewTickInterval = null;
148 let focusWorkMinutes = 25;
149 let focusBreakMinutes = 5;
150
151 /**
152 * Format elapsed time since a start timestamp as h:mm:ss or m:ss.
153 * @param {string} startedAt - ISO 8601 start timestamp
154 * @returns {string} Formatted elapsed time
155 */
156 function fmtElapsed(startedAt) {
157 const diff = Math.max(0, Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000));
158 const h = Math.floor(diff / 3600);
159 const m = Math.floor((diff % 3600) / 60);
160 const s = diff % 60;
161 return h > 0
162 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
163 : `${m}:${String(s).padStart(2, '0')}`;
164 }
165
166 function clearSubviewTick() {
167 if (subviewTickInterval) {
168 clearInterval(subviewTickInterval);
169 subviewTickInterval = null;
170 }
171 }
172
173 /**
174 * Load and render the Timer sub-view with active session, focus split, and task list.
175 */
176 async function loadTimerView() {
177 const container = document.getElementById('timer-subview-content');
178 if (!container) return;
179 console.log('[timer] loadTimerView start');
180
181 // Fetch data independently so one failure doesn't block the rest
182 let activeResult = null;
183 let tasks = [];
184
185 try {
186 activeResult = await GoingsOn.api.timeTracking.getActive();
187 console.log('[timer] getActive result:', activeResult);
188 } catch (err) {
189 console.error('[timer] getActive failed:', err);
190 }
191
192 try {
193 const [pendingResp, startedResp] = await Promise.all([
194 GoingsOn.api.tasks.listFiltered({ status: 'Pending', showSnoozed: false, limit: 200 }),
195 GoingsOn.api.tasks.listFiltered({ status: 'Started', showSnoozed: false, limit: 200 }),
196 ]);
197 console.log('[timer] listFiltered results — pending:', pendingResp?.tasks?.length, 'started:', startedResp?.tasks?.length);
198 const pending = pendingResp?.tasks || [];
199 const started = startedResp?.tasks || [];
200 // Started first (more likely to be tracked), then pending
201 const allTasks = [...started, ...pending];
202 // Remove the actively-timed task from the list
203 const activeTaskId = activeResult?.taskId;
204 tasks = activeTaskId ? allTasks.filter(t => t.id !== activeTaskId) : allTasks;
205 } catch (err) {
206 console.error('[timer] listFiltered failed:', err);
207 }
208
209 clearSubviewTick();
210
211 let html = '';
212
213 // ---- Active session banner ----
214 if (activeResult) {
215 html += `
216 <div class="timer-active-banner">
217 <div class="timer-active-info">
218 <span class="timer-active-label">Tracking</span>
219 <span class="timer-active-task">${esc(activeResult.taskDescription)}</span>
220 </div>
221 <span class="timer-active-elapsed" id="timer-subview-elapsed">${fmtElapsed(activeResult.session.startedAt)}</span>
222 <div class="timer-active-actions">
223 <button class="btn btn-sm btn-primary" onclick="GoingsOn.timeTracking.stopAndRefreshTimerView()">Stop</button>
224 <button class="btn btn-sm btn-ghost" onclick="GoingsOn.timeTracking.discardAndRefreshTimerView()">Discard</button>
225 </div>
226 </div>`;
227
228 const startMs = new Date(activeResult.session.startedAt).getTime();
229 subviewTickInterval = setInterval(() => {
230 const el = document.getElementById('timer-subview-elapsed');
231 if (!el) { clearSubviewTick(); return; }
232 const diff = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
233 const h = Math.floor(diff / 3600);
234 const m = Math.floor((diff % 3600) / 60);
235 const s = diff % 60;
236 el.textContent = h > 0
237 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
238 : `${m}:${String(s).padStart(2, '0')}`;
239 }, 1000);
240 }
241
242 // ---- Focus split inputs ----
243 html += `
244 <div class="timer-focus-split">
245 <span class="timer-focus-split-label">Focus split:</span>
246 <input type="number" id="focus-work-minutes" class="timer-split-input" value="${focusWorkMinutes}" min="1" max="240" onchange="GoingsOn.timeTracking.updateFocusSplit()">
247 <span class="timer-focus-split-sep">work /</span>
248 <input type="number" id="focus-break-minutes" class="timer-split-input" value="${focusBreakMinutes}" min="1" max="60" onchange="GoingsOn.timeTracking.updateFocusSplit()">
249 <span class="timer-focus-split-sep">break</span>
250 </div>`;
251
252 // ---- Task list ----
253 if (tasks.length === 0 && !activeResult) {
254 html += `<p class="time-tracking-empty">No pending or started tasks to track.</p>`;
255 } else if (tasks.length > 0) {
256 const hasActive = !!activeResult;
257 html += `<div class="timer-task-list">`;
258 for (const task of tasks) {
259 const project = task.projectName ? `<span class="timer-task-project">${esc(task.projectName)}</span>` : '';
260 const pri = task.priority ? `<span class="timer-task-priority priority-${task.priority.toLowerCase()}">${esc(task.priority)}</span>` : '';
261 const est = task.estimatedMinutes ? `<span class="timer-task-estimate">${task.estimatedMinutes}m est</span>` : '';
262 const tracked = task.actualMinutes > 0 ? `<span class="timer-task-tracked">${task.actualMinutes}m tracked</span>` : '';
263 const disabled = hasActive ? ' disabled' : '';
264
265 html += `
266 <div class="timer-task-item">
267 <div class="timer-task-info">
268 <span class="timer-task-desc">${esc(task.description)}</span>
269 <div class="timer-task-meta">
270 ${project}${pri}${est}${tracked}
271 </div>
272 </div>
273 <div class="timer-task-actions">
274 <button class="btn btn-sm btn-primary" onclick="GoingsOn.timeTracking.trackFromTimerView('${escAttr(task.id)}')"${disabled} title="Start open-ended timer">Track</button>
275 <button class="btn btn-sm btn-secondary" onclick="GoingsOn.timeTracking.focusFromTimerView('${escAttr(task.id)}')"${disabled} title="Start ${focusWorkMinutes}/${focusBreakMinutes} focus session">Focus</button>
276 <button class="btn btn-sm btn-ghost" onclick="GoingsOn.timeTracking.openLogTimeModal('${escAttr(task.id)}')" title="Log time retroactively">Log</button>
277 </div>
278 </div>`;
279 }
280 html += `</div>`;
281 }
282
283 container.innerHTML = html;
284 }
285
286 function updateFocusSplit() {
287 const workEl = document.getElementById('focus-work-minutes');
288 const breakEl = document.getElementById('focus-break-minutes');
289 if (workEl) focusWorkMinutes = Math.max(1, Math.min(240, parseInt(workEl.value, 10) || 25));
290 if (breakEl) focusBreakMinutes = Math.max(1, Math.min(60, parseInt(breakEl.value, 10) || 5));
291 // Update Focus button titles
292 document.querySelectorAll('.timer-task-actions button:last-child').forEach(btn => {
293 if (btn.textContent.trim() === 'Focus') {
294 btn.title = `Start ${focusWorkMinutes}/${focusBreakMinutes} focus session`;
295 }
296 });
297 }
298
299 async function trackFromTimerView(taskId) {
300 try {
301 await startTimer(taskId);
302 } catch (err) {
303 // startTimer already shows toast
304 }
305 await loadTimerView();
306 }
307
308 async function focusFromTimerView(taskId) {
309 if (GoingsOn.focusTimer?.start) {
310 await GoingsOn.focusTimer.start(taskId, {
311 workMinutes: focusWorkMinutes,
312 breakMinutes: focusBreakMinutes,
313 });
314 }
315 }
316
317 async function stopAndRefreshTimerView() {
318 try {
319 const result = await GoingsOn.api.timeTracking.getActive();
320 if (result) {
321 const session = await GoingsOn.api.timeTracking.stopTimer(result.taskId);
322 if (session) {
323 const mins = session.durationMinutes || 0;
324 const display = mins >= 60
325 ? `${Math.floor(mins / 60)}h ${mins % 60}m`
326 : `${mins}m`;
327 GoingsOn.ui.showToast(`Tracked ${display}`, 'success');
328 }
329 }
330 hideWidget();
331 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
332 } catch (err) {
333 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to stop timer'), 'error');
334 }
335 await loadTimerView();
336 }
337
338 async function discardAndRefreshTimerView() {
339 try {
340 const result = await GoingsOn.api.timeTracking.getActive();
341 if (result) {
342 await GoingsOn.api.timeTracking.discardTimer(result.taskId);
343 }
344 hideWidget();
345 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
346 GoingsOn.ui.showToast('Timer discarded', 'info');
347 } catch (err) {
348 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to discard timer'), 'error');
349 }
350 await loadTimerView();
351 }
352
353 // ============ Init ============
354
355 function init() {
356 createWidget();
357 checkActive();
358 }
359
360 // ============ Manual Time Entry ============
361
362 function openLogTimeModal(taskId) {
363 const content = `
364 <form id="log-time-form" onsubmit="GoingsOn.timeTracking.submitLogTime(event, '${GoingsOn.utils.escapeAttr(taskId)}')">
365 <div class="form-group">
366 <label class="form-label" for="log-time-minutes">Duration (minutes)</label>
367 <input type="number" class="form-input" id="log-time-minutes" name="minutes" required min="1" max="1440" placeholder="30" autofocus>
368 </div>
369 <div class="form-group">
370 <label class="form-label" for="log-time-date">Date</label>
371 <input type="date" class="form-input" id="log-time-date" name="date" value="${new Date().toISOString().split('T')[0]}">
372 </div>
373 <div class="form-actions">
374 <button type="button" class="btn btn-secondary" onclick="GoingsOn.ui.closeModal()">Cancel</button>
375 <button type="submit" class="btn btn-primary">Log Time</button>
376 </div>
377 </form>
378 `;
379 GoingsOn.ui.openModal('Log Time', content);
380 }
381
382 async function submitLogTime(e, taskId) {
383 e.preventDefault();
384 const form = e.target;
385 const minutes = parseInt(form.minutes.value, 10);
386 const dateStr = form.date.value;
387
388 if (!minutes || minutes < 1) return;
389
390 // Convert date to UTC datetime (noon on selected day)
391 const date = new Date(dateStr + 'T12:00:00Z').toISOString();
392
393 try {
394 await GoingsOn.api.timeTracking.logManual(taskId, minutes, date);
395 const display = minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
396 GoingsOn.ui.showToast(`Logged ${display}`, 'success');
397 GoingsOn.ui.closeModal();
398 await loadTimerView();
399 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
400 } catch (err) {
401 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to log time'), 'error');
402 }
403 }
404
405 // ============ Namespace ============
406
407 GoingsOn.timeTracking = {
408 init,
409 startTimer,
410 stopActive,
411 discardActive,
412 checkActive,
413 loadTimerView,
414 trackFromTimerView,
415 focusFromTimerView,
416 stopAndRefreshTimerView,
417 discardAndRefreshTimerView,
418 updateFocusSplit,
419 openLogTimeModal,
420 submitLogTime,
421 };
422
423 })();
424