Skip to main content

max / goingson

8.9 KB · 278 lines History Blame Raw
1 /**
2 * @fileoverview Pomodoro focus timer — frontend-only countdown using start/stop commands.
3 *
4 * Uses the same backend timer (start_timer/stop_timer) but adds a JS countdown
5 * overlay. On countdown end: notification sound, auto-stop, break prompt.
6 * Break timer is pure JS (no DB tracking).
7 */
8 (function() {
9 'use strict';
10
11 const PRESETS = {
12 standard: { work: 25, break: 5, label: '25/5' },
13 deep: { work: 50, break: 10, label: '50/10' },
14 };
15
16 let countdownInterval = null;
17 let remainingSeconds = 0;
18 let currentTaskId = null;
19 let currentPreset = 'standard';
20 let customWorkMinutes = null;
21 let customBreakMinutes = null;
22 let isBreak = false;
23
24 // ============ Focus Mode UI ============
25
26 function createOverlay() {
27 if (document.getElementById('focus-overlay')) return;
28 const overlay = document.createElement('div');
29 overlay.id = 'focus-overlay';
30 overlay.className = 'focus-overlay hidden';
31 overlay.innerHTML = `
32 <div class="focus-overlay-content">
33 <div class="focus-header">
34 <span class="focus-label">Focus Mode</span>
35 <div class="focus-presets">
36 <button class="btn btn-sm focus-preset-btn" data-preset="standard">25/5</button>
37 <button class="btn btn-sm focus-preset-btn" data-preset="deep">50/10</button>
38 </div>
39 </div>
40 <div class="focus-countdown"></div>
41 <div class="focus-progress-bar"><div class="focus-progress-fill"></div></div>
42 <div class="focus-task-name"></div>
43 <div class="focus-actions">
44 <button class="btn btn-primary focus-stop-btn">Stop</button>
45 <button class="btn btn-ghost focus-cancel-btn">Cancel</button>
46 </div>
47 </div>
48 `;
49 document.body.appendChild(overlay);
50
51 overlay.querySelector('.focus-stop-btn').addEventListener('click', stopFocus);
52 overlay.querySelector('.focus-cancel-btn').addEventListener('click', cancelFocus);
53 overlay.querySelectorAll('.focus-preset-btn').forEach(btn => {
54 btn.addEventListener('click', () => {
55 if (!currentTaskId || isBreak) return;
56 selectPreset(btn.dataset.preset);
57 });
58 });
59 }
60
61 function selectPreset(preset) {
62 currentPreset = preset;
63 const overlay = document.getElementById('focus-overlay');
64 if (!overlay) return;
65 overlay.querySelectorAll('.focus-preset-btn').forEach(btn => {
66 btn.classList.toggle('active', btn.dataset.preset === preset);
67 });
68 }
69
70 function showOverlay(taskDescription) {
71 const overlay = document.getElementById('focus-overlay');
72 if (!overlay) return;
73 overlay.querySelector('.focus-task-name').textContent = taskDescription;
74 overlay.querySelector('.focus-label').textContent = isBreak ? 'Break Time' : 'Focus Mode';
75 overlay.classList.remove('hidden');
76 selectPreset(currentPreset);
77 updateCountdownDisplay();
78 }
79
80 function hideOverlay() {
81 const overlay = document.getElementById('focus-overlay');
82 if (overlay) overlay.classList.add('hidden');
83 }
84
85 function updateCountdownDisplay() {
86 const overlay = document.getElementById('focus-overlay');
87 if (!overlay) return;
88 const m = Math.floor(remainingSeconds / 60);
89 const s = remainingSeconds % 60;
90 overlay.querySelector('.focus-countdown').textContent =
91 `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
92
93 const preset = currentPreset ? (PRESETS[currentPreset] || PRESETS.standard) : null;
94 const totalSeconds = isBreak
95 ? (customBreakMinutes || preset?.break || 5) * 60
96 : (customWorkMinutes || preset?.work || 25) * 60;
97 const progress = totalSeconds > 0
98 ? ((totalSeconds - remainingSeconds) / totalSeconds) * 100
99 : 0;
100 overlay.querySelector('.focus-progress-fill').style.width = `${progress}%`;
101 }
102
103 // ============ Timer Logic ============
104
105 /**
106 * Start a focus mode session (Pomodoro) for a task.
107 * Starts the backend timer and shows a countdown overlay.
108 * @param {string} taskId - Task ID to focus on
109 * @param {Object} [options] - Optional settings
110 * @param {number} [options.workMinutes] - Custom work duration in minutes
111 * @param {number} [options.breakMinutes] - Custom break duration in minutes
112 * @param {string} [options.preset] - Named preset ('standard' or 'deep')
113 */
114 async function start(taskId, options = {}) {
115 currentTaskId = taskId;
116 isBreak = false;
117
118 // Support custom work/break minutes or a named preset
119 if (options.workMinutes) {
120 customWorkMinutes = options.workMinutes;
121 customBreakMinutes = options.breakMinutes || Math.round(options.workMinutes / 5);
122 currentPreset = null;
123 remainingSeconds = customWorkMinutes * 60;
124 } else {
125 const presetName = (typeof options === 'string') ? options : (options.preset || 'standard');
126 currentPreset = presetName;
127 customWorkMinutes = null;
128 customBreakMinutes = null;
129 const preset = PRESETS[currentPreset] || PRESETS.standard;
130 remainingSeconds = preset.work * 60;
131 }
132
133 try {
134 // Start the backend timer
135 await GoingsOn.api.timeTracking.startTimer(taskId);
136 } catch (err) {
137 // Timer may already be running — that's OK for focus mode
138 const msg = GoingsOn.utils.getErrorMessage(err);
139 if (!msg.includes('already running')) {
140 GoingsOn.ui.showToast(msg, 'error');
141 return;
142 }
143 }
144
145 createOverlay();
146
147 // Get task description for display
148 try {
149 const active = await GoingsOn.api.timeTracking.getActive();
150 showOverlay(active?.taskDescription || 'Task');
151 } catch {
152 showOverlay('Task');
153 }
154
155 startCountdown();
156 GoingsOn.timeTracking.checkActive();
157 }
158
159 function startCountdown() {
160 if (countdownInterval) clearInterval(countdownInterval);
161 countdownInterval = setInterval(() => {
162 remainingSeconds--;
163 updateCountdownDisplay();
164 if (remainingSeconds <= 0) {
165 clearInterval(countdownInterval);
166 countdownInterval = null;
167 onCountdownEnd();
168 }
169 }, 1000);
170 }
171
172 async function onCountdownEnd() {
173 if (isBreak) {
174 // Break finished
175 hideOverlay();
176 GoingsOn.ui.showToast('Break over. Ready to focus again!', 'info');
177 return;
178 }
179
180 // Work session finished — auto-stop the backend timer
181 try {
182 if (currentTaskId) {
183 const session = await GoingsOn.api.timeTracking.stopTimer(currentTaskId);
184 if (session) {
185 const mins = session.durationMinutes || 0;
186 GoingsOn.ui.showToast(`Focus session complete! Tracked ${mins}m`, 'success');
187 }
188 }
189 } catch (err) {
190 console.error('Failed to stop timer on focus end:', err);
191 }
192
193 GoingsOn.timeTracking.checkActive();
194 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
195
196 // Prompt for break
197 promptBreak();
198 }
199
200 function promptBreak() {
201 isBreak = true;
202 if (customBreakMinutes) {
203 remainingSeconds = customBreakMinutes * 60;
204 } else {
205 const preset = PRESETS[currentPreset] || PRESETS.standard;
206 remainingSeconds = preset.break * 60;
207 }
208 showOverlay('Break Time');
209 startCountdown();
210 }
211
212 /**
213 * Stop the active focus session, recording tracked time to the backend.
214 */
215 async function stopFocus() {
216 if (countdownInterval) {
217 clearInterval(countdownInterval);
218 countdownInterval = null;
219 }
220
221 if (!isBreak && currentTaskId) {
222 try {
223 const session = await GoingsOn.api.timeTracking.stopTimer(currentTaskId);
224 if (session) {
225 const mins = session.durationMinutes || 0;
226 const display = mins >= 60
227 ? `${Math.floor(mins / 60)}h ${mins % 60}m`
228 : `${mins}m`;
229 GoingsOn.ui.showToast(`Focus stopped. Tracked ${display}`, 'success');
230 }
231 } catch (err) {
232 console.error('Failed to stop focus timer:', err);
233 }
234 GoingsOn.timeTracking.checkActive();
235 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
236 }
237
238 hideOverlay();
239 currentTaskId = null;
240 isBreak = false;
241 }
242
243 /**
244 * Cancel the active focus session, discarding any tracked time.
245 */
246 async function cancelFocus() {
247 if (countdownInterval) {
248 clearInterval(countdownInterval);
249 countdownInterval = null;
250 }
251
252 if (!isBreak && currentTaskId) {
253 try {
254 await GoingsOn.api.timeTracking.discardTimer(currentTaskId);
255 } catch (err) {
256 console.error('Failed to discard focus timer:', err);
257 }
258 GoingsOn.timeTracking.checkActive();
259 if (GoingsOn.tasks?.load) GoingsOn.tasks.load();
260 }
261
262 hideOverlay();
263 currentTaskId = null;
264 isBreak = false;
265 GoingsOn.ui.showToast('Focus session cancelled', 'info');
266 }
267
268 // ============ Namespace ============
269
270 GoingsOn.focusTimer = {
271 start,
272 stop: stopFocus,
273 cancel: cancelFocus,
274 PRESETS,
275 };
276
277 })();
278