| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 135 |
await GoingsOn.api.timeTracking.startTimer(taskId); |
| 136 |
} catch (err) { |
| 137 |
|
| 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 |
|
| 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 |
|
| 175 |
hideOverlay(); |
| 176 |
GoingsOn.ui.showToast('Break over. Ready to focus again!', 'info'); |
| 177 |
return; |
| 178 |
} |
| 179 |
|
| 180 |
|
| 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 |
|
| 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 |
|
| 269 |
|
| 270 |
GoingsOn.focusTimer = { |
| 271 |
start, |
| 272 |
stop: stopFocus, |
| 273 |
cancel: cancelFocus, |
| 274 |
PRESETS, |
| 275 |
}; |
| 276 |
|
| 277 |
})(); |
| 278 |
|