Skip to main content

max / goingson

15.9 KB · 421 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 const escAttr = (s) => GoingsOn.utils.escapeAttrValue(s);
13 const escArg = (s) => GoingsOn.utils.escapeHandlerArg(s);
14
15 let tickInterval = null;
16 let activeTimer = null; // { taskId, taskDescription, startedAt }
17
18 // ============ Timer Widget ============
19
20 function createWidget() {
21 if (document.getElementById('timer-widget')) return;
22 const widget = document.createElement('div');
23 widget.id = 'timer-widget';
24 widget.className = 'timer-widget hidden';
25 widget.innerHTML = `
26 <div class="timer-widget-inner">
27 <span class="timer-task-name"></span>
28 <span class="timer-elapsed"></span>
29 <div class="timer-actions">
30 <button class="btn btn-sm btn-primary timer-stop-btn" title="Stop timer">Stop</button>
31 <button class="btn btn-sm btn-ghost timer-discard-btn" title="Discard timer">Discard</button>
32 </div>
33 </div>
34 `;
35 document.body.appendChild(widget);
36
37 widget.querySelector('.timer-stop-btn').addEventListener('click', stopActive);
38 widget.querySelector('.timer-discard-btn').addEventListener('click', discardActive);
39 }
40
41 function showWidget(taskDescription, startedAt) {
42 const widget = document.getElementById('timer-widget');
43 if (!widget) return;
44 widget.querySelector('.timer-task-name').textContent = taskDescription;
45 widget.classList.remove('hidden');
46 activeTimer = { startedAt: new Date(startedAt) };
47 updateElapsed();
48 if (tickInterval) clearInterval(tickInterval);
49 tickInterval = setInterval(updateElapsed, 1000);
50 }
51
52 function hideWidget() {
53 const widget = document.getElementById('timer-widget');
54 if (widget) widget.classList.add('hidden');
55 if (tickInterval) {
56 clearInterval(tickInterval);
57 tickInterval = null;
58 }
59 activeTimer = null;
60 }
61
62 function updateElapsed() {
63 if (!activeTimer) return;
64 const widget = document.getElementById('timer-widget');
65 if (!widget) return;
66 const diff = Math.floor((Date.now() - activeTimer.startedAt.getTime()) / 1000);
67 const h = Math.floor(diff / 3600);
68 const m = Math.floor((diff % 3600) / 60);
69 const s = diff % 60;
70 const display = h > 0
71 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
72 : `${m}:${String(s).padStart(2, '0')}`;
73 widget.querySelector('.timer-elapsed').textContent = display;
74 }
75
76 // ============ Actions ============
77
78 /**
79 * Start a time tracking timer for a task. Shows the floating widget.
80 * @param {string} taskId - Task ID to track time for
81 */
82 async function startTimer(taskId) {
83 try {
84 await GoingsOn.api.timeTracking.startTimer(taskId);
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
180 // Fetch data independently so one failure doesn't block the rest
181 let activeResult = null;
182 let tasks = [];
183
184 try {
185 activeResult = await GoingsOn.api.timeTracking.getActive();
186 } catch (err) {
187 console.error('[timer] getActive failed:', err);
188 }
189
190 try {
191 const [pendingResp, startedResp] = await Promise.all([
192 GoingsOn.api.tasks.listFiltered({ status: 'Pending', showSnoozed: false, limit: 200 }),
193 GoingsOn.api.tasks.listFiltered({ status: 'Started', showSnoozed: false, limit: 200 }),
194 ]);
195 const pending = pendingResp?.tasks || [];
196 const started = startedResp?.tasks || [];
197 // Started first (more likely to be tracked), then pending
198 const allTasks = [...started, ...pending];
199 // Remove the actively-timed task from the list
200 const activeTaskId = activeResult?.taskId;
201 tasks = activeTaskId ? allTasks.filter(t => t.id !== activeTaskId) : allTasks;
202 } catch (err) {
203 console.error('[timer] listFiltered failed:', err);
204 }
205
206 clearSubviewTick();
207
208 let html = '';
209
210 // ---- Active session banner ----
211 if (activeResult) {
212 html += `
213 <div class="timer-active-banner">
214 <div class="timer-active-info">
215 <span class="timer-active-label">Tracking</span>
216 <span class="timer-active-task">${esc(activeResult.taskDescription)}</span>
217 </div>
218 <span class="timer-active-elapsed" id="timer-subview-elapsed">${fmtElapsed(activeResult.session.startedAt)}</span>
219 <div class="timer-active-actions">
220 <button class="btn btn-sm btn-primary" data-act="timeTracking.stopAndRefreshTimerView">Stop</button>
221 <button class="btn btn-sm btn-ghost" data-act="timeTracking.discardAndRefreshTimerView">Discard</button>
222 </div>
223 </div>`;
224
225 const startMs = new Date(activeResult.session.startedAt).getTime();
226 subviewTickInterval = setInterval(() => {
227 const el = document.getElementById('timer-subview-elapsed');
228 if (!el) { clearSubviewTick(); return; }
229 const diff = Math.max(0, Math.floor((Date.now() - startMs) / 1000));
230 const h = Math.floor(diff / 3600);
231 const m = Math.floor((diff % 3600) / 60);
232 const s = diff % 60;
233 el.textContent = h > 0
234 ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
235 : `${m}:${String(s).padStart(2, '0')}`;
236 }, 1000);
237 }
238
239 // ---- Focus split inputs ----
240 html += `
241 <div class="timer-focus-split">
242 <span class="timer-focus-split-label">Focus split:</span>
243 <input type="number" id="focus-work-minutes" class="timer-split-input" value="${focusWorkMinutes}" min="1" max="240" data-change="timeTracking.updateFocusSplit">
244 <span class="timer-focus-split-sep">work /</span>
245 <input type="number" id="focus-break-minutes" class="timer-split-input" value="${focusBreakMinutes}" min="1" max="60" data-change="timeTracking.updateFocusSplit">
246 <span class="timer-focus-split-sep">break</span>
247 </div>`;
248
249 // ---- Task list ----
250 if (tasks.length === 0 && !activeResult) {
251 html += `<p class="time-tracking-empty">No pending or started tasks to track.</p>`;
252 } else if (tasks.length > 0) {
253 const hasActive = !!activeResult;
254 html += `<div class="timer-task-list">`;
255 for (const task of tasks) {
256 const project = task.projectName ? `<span class="timer-task-project">${esc(task.projectName)}</span>` : '';
257 const pri = task.priority ? `<span class="timer-task-priority priority-${task.priority.toLowerCase()}">${esc(task.priority)}</span>` : '';
258 const est = task.estimatedMinutes ? `<span class="timer-task-estimate">${task.estimatedMinutes}m est</span>` : '';
259 const tracked = task.actualMinutes > 0 ? `<span class="timer-task-tracked">${task.actualMinutes}m tracked</span>` : '';
260 const disabled = hasActive ? ' disabled' : '';
261
262 html += `
263 <div class="timer-task-item">
264 <div class="timer-task-info">
265 <span class="timer-task-desc">${esc(task.description)}</span>
266 <div class="timer-task-meta">
267 ${project}${pri}${est}${tracked}
268 </div>
269 </div>
270 <div class="timer-task-actions">
271 <button class="btn btn-sm btn-primary" data-act="timeTracking.trackFromTimerView" data-a1="${escAttr(task.id)}"${disabled} title="Start open-ended timer">Track</button>
272 <button class="btn btn-sm btn-secondary" data-act="timeTracking.focusFromTimerView" data-a1="${escAttr(task.id)}"${disabled} title="Start ${focusWorkMinutes}/${focusBreakMinutes} focus session">Focus</button>
273 <button class="btn btn-sm btn-ghost" data-act="timeTracking.openLogTimeModal" data-a1="${escAttr(task.id)}" title="Log time retroactively">Log</button>
274 </div>
275 </div>`;
276 }
277 html += `</div>`;
278 }
279
280 container.innerHTML = html;
281 }
282
283 function updateFocusSplit() {
284 const workEl = document.getElementById('focus-work-minutes');
285 const breakEl = document.getElementById('focus-break-minutes');
286 if (workEl) focusWorkMinutes = Math.max(1, Math.min(240, parseInt(workEl.value, 10) || 25));
287 if (breakEl) focusBreakMinutes = Math.max(1, Math.min(60, parseInt(breakEl.value, 10) || 5));
288 // Update Focus button titles
289 document.querySelectorAll('.timer-task-actions button:last-child').forEach(btn => {
290 if (btn.textContent.trim() === 'Focus') {
291 btn.title = `Start ${focusWorkMinutes}/${focusBreakMinutes} focus session`;
292 }
293 });
294 }
295
296 async function trackFromTimerView(taskId) {
297 try {
298 await startTimer(taskId);
299 } catch (err) {
300 // startTimer already shows toast
301 }
302 await loadTimerView();
303 }
304
305 async function focusFromTimerView(taskId) {
306 if (GoingsOn.focusTimer?.start) {
307 await GoingsOn.focusTimer.start(taskId, {
308 workMinutes: focusWorkMinutes,
309 breakMinutes: focusBreakMinutes,
310 });
311 }
312 }
313
314 async function stopAndRefreshTimerView() {
315 try {
316 const result = await GoingsOn.api.timeTracking.getActive();
317 if (result) {
318 const session = await GoingsOn.api.timeTracking.stopTimer(result.taskId);
319 if (session) {
320 const mins = session.durationMinutes || 0;
321 const display = mins >= 60
322 ? `${Math.floor(mins / 60)}h ${mins % 60}m`
323 : `${mins}m`;
324 GoingsOn.ui.showToast(`Tracked ${display}`, 'success');
325 }
326 }
327 hideWidget();
328 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
329 } catch (err) {
330 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to stop timer'), 'error');
331 }
332 await loadTimerView();
333 }
334
335 async function discardAndRefreshTimerView() {
336 try {
337 const result = await GoingsOn.api.timeTracking.getActive();
338 if (result) {
339 await GoingsOn.api.timeTracking.discardTimer(result.taskId);
340 }
341 hideWidget();
342 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
343 GoingsOn.ui.showToast('Timer discarded', 'info');
344 } catch (err) {
345 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to discard timer'), 'error');
346 }
347 await loadTimerView();
348 }
349
350 // ============ Init ============
351
352 function init() {
353 createWidget();
354 checkActive();
355 }
356
357 // ============ Manual Time Entry ============
358
359 function openLogTimeModal(taskId) {
360 const content = `
361 <form id="log-time-form" data-submit="timeTracking.submitLogTime" data-a1="@event" data-a2="${escAttr(taskId)}">
362 <div class="form-group">
363 <label class="form-label" for="log-time-minutes">Duration (minutes)</label>
364 <input type="number" class="form-input" id="log-time-minutes" name="minutes" required min="1" max="1440" placeholder="30" autofocus>
365 </div>
366 <div class="form-group">
367 <label class="form-label" for="log-time-date">Date</label>
368 <input type="date" class="form-input" id="log-time-date" name="date" value="${new Date().toISOString().split('T')[0]}">
369 </div>
370 <div class="form-actions">
371 <button type="button" class="btn btn-secondary" data-act="ui.closeModal">Cancel</button>
372 <button type="submit" class="btn btn-primary">Log Time</button>
373 </div>
374 </form>
375 `;
376 GoingsOn.ui.openModal('Log Time', content);
377 }
378
379 async function submitLogTime(e, taskId) {
380 e.preventDefault();
381 const form = e.target;
382 const minutes = parseInt(form.minutes.value, 10);
383 const dateStr = form.date.value;
384
385 if (!minutes || minutes < 1) return;
386
387 // Convert date to UTC datetime (noon on selected day)
388 const date = new Date(dateStr + 'T12:00:00Z').toISOString();
389
390 try {
391 await GoingsOn.api.timeTracking.logManual(taskId, minutes, date);
392 const display = minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes}m`;
393 GoingsOn.ui.showToast(`Logged ${display}`, 'success');
394 GoingsOn.ui.closeModal();
395 await loadTimerView();
396 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
397 } catch (err) {
398 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to log time'), 'error');
399 }
400 }
401
402 // ============ Namespace ============
403
404 GoingsOn.timeTracking = {
405 init,
406 startTimer,
407 stopActive,
408 discardActive,
409 checkActive,
410 loadTimerView,
411 trackFromTimerView,
412 focusFromTimerView,
413 stopAndRefreshTimerView,
414 discardAndRefreshTimerView,
415 updateFocusSplit,
416 openLogTimeModal,
417 submitLogTime,
418 };
419
420 })();
421