// ── State ───────────────────────────────────────────────────────────────────── const _t = { mode: 'pomodoro', // 'pomodoro' | 'freeflow' phase: 'work', // 'work' | 'break' secondsElapsed: 0, running: false, intervalId: null, }; const _TIMER_LS_KEY = 'focus_buddy_timer'; function _saveTimerState() { const workEl = document.getElementById('work-dur'); const breakEl = document.getElementById('break-dur'); try { localStorage.setItem(_TIMER_LS_KEY, JSON.stringify({ mode: _t.mode, phase: _t.phase, secondsElapsed: _t.secondsElapsed, workDur: workEl?.value || '25', breakDur: breakEl?.value || '5', })); } catch (_) {} } function _restoreTimerState() { try { const saved = JSON.parse(localStorage.getItem(_TIMER_LS_KEY) || 'null'); if (!saved) return; _t.mode = saved.mode || 'pomodoro'; _t.phase = saved.phase || 'work'; _t.secondsElapsed = saved.secondsElapsed || 0; const workEl = document.getElementById('work-dur'); const breakEl = document.getElementById('break-dur'); if (workEl) workEl.value = saved.workDur || '25'; if (breakEl) breakEl.value = saved.breakDur || '5'; } catch (_) {} } // ── Helpers ─────────────────────────────────────────────────────────────────── function _workSecs() { return (parseInt(document.getElementById('work-dur')?.value) || 25) * 60; } function _breakSecs() { return (parseInt(document.getElementById('break-dur')?.value) || 5) * 60; } function _targetSecs() { if (_t.mode === 'freeflow') return Infinity; return _t.phase === 'work' ? _workSecs() : _breakSecs(); } function _fmt(secs) { const m = Math.floor(secs / 60).toString().padStart(2, '0'); const s = (secs % 60).toString().padStart(2, '0'); return `${m}:${s}`; } function _setDisplay() { const elapsed = document.getElementById('timer-elapsed'); const target = document.getElementById('timer-target'); if (elapsed) elapsed.textContent = _fmt(_t.secondsElapsed); if (target) { if (_t.mode === 'pomodoro') { target.textContent = ' / ' + _fmt(_t.phase === 'work' ? _workSecs() : _breakSecs()); } else { target.textContent = ''; } } } function _setPhaseLabel(text) { const el = document.getElementById('timer-phase'); if (el) el.textContent = text; } function _setBreakStyle(isBreak) { document.getElementById('timer-display')?.classList.toggle('break-phase', isBreak); } function _setModeButtonActive(mode) { document.getElementById('btn-pomodoro')?.classList.toggle('active', mode === 'pomodoro'); document.getElementById('btn-freeflow')?.classList.toggle('active', mode === 'freeflow'); } function _updateStartBtn() { const btn = document.getElementById('btn-start-stop'); if (!btn) return; if (_t.running) { btn.textContent = '■ Stop'; btn.classList.add('stop-btn'); btn.classList.remove('start-btn'); } else { btn.textContent = '▶ Start'; btn.classList.remove('stop-btn'); btn.classList.add('start-btn'); } const disabled = _t.running; document.getElementById('btn-pomodoro')?.toggleAttribute('disabled', disabled); document.getElementById('btn-freeflow')?.toggleAttribute('disabled', disabled); // Drive the 3D avatar's base animation off the running state and phase. if (window.avatarSetBase) { if (_t.running) { window.avatarSetBase(_t.phase === 'break' ? 'rest' : 'working'); } else { window.avatarSetBase('idle'); } } } // ── Chime (Web Audio API) ───────────────────────────────────────────────────── function _playChime() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); [[880, 0, 0.6], [1109, 0.15, 0.4], [880, 0.4, 0.25]].forEach(([freq, delay, vol]) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.type = 'sine'; osc.frequency.value = freq; const t = ctx.currentTime + delay; gain.gain.setValueAtTime(vol, t); gain.gain.exponentialRampToValueAtTime(0.001, t + 1.8); osc.start(t); osc.stop(t + 1.9); }); } catch (_) {} } // ── Core timer logic ────────────────────────────────────────────────────────── function _tick() { _t.secondsElapsed++; _setDisplay(); if (_t.secondsElapsed % 30 === 0) _saveTimerState(); if (_t.secondsElapsed < _targetSecs()) return; // Reached target _stopInterval(); _onComplete(); } function _stopInterval() { if (_t.intervalId !== null) { clearInterval(_t.intervalId); _t.intervalId = null; } _t.running = false; _updateStartBtn(); _saveTimerState(); } function _onComplete() { _playChime(); if (window.avatarPlay) window.avatarPlay('celebrate'); const el = document.getElementById('timer-display'); if (el) { el.classList.add('ringing'); setTimeout(() => el.classList.remove('ringing'), 3000); } if (_t.mode === 'pomodoro') { if (_t.phase === 'work') { bridgeSend('timer_complete', { mode: 'pomodoro', phase: 'work' }); _t.phase = 'break'; _t.secondsElapsed = 0; _setPhaseLabel('☕ Break Time'); _setBreakStyle(true); } else { bridgeSend('timer_complete', { mode: 'pomodoro', phase: 'break' }); _t.phase = 'work'; _t.secondsElapsed = 0; _setPhaseLabel('⚔️ Work Session'); _setBreakStyle(false); } } else { bridgeSend('timer_complete', { mode: 'freeflow', phase: 'work' }); _t.secondsElapsed = 0; _setPhaseLabel('🌊 Free Flow'); } _setDisplay(); } // ── Public API ──────────────────────────────────────────────────────────────── function timerToggle() { if (_t.running) { // Capture state before stopping const elapsedAtStop = _t.secondsElapsed; const phaseAtStop = _t.phase; const modeAtStop = _t.mode; const targetAtStop = _t.mode === 'pomodoro' ? (phaseAtStop === 'work' ? _workSecs() : _breakSecs()) : null; _stopInterval(); // Send XP event for partial work completed if (elapsedAtStop > 0) { if (phaseAtStop === 'work' && window.avatarPlay) window.avatarPlay('celebrate'); const payload = { mode: modeAtStop, phase: phaseAtStop, seconds_elapsed: elapsedAtStop }; if (targetAtStop !== null) payload.target_secs = targetAtStop; bridgeSend('timer_stop', payload); } // Reset display to 00:00, back to work phase _t.phase = 'work'; _t.secondsElapsed = 0; _setBreakStyle(false); _setDisplay(); _setPhaseLabel(modeAtStop === 'pomodoro' ? '⚔️ Work Session' : '🌊 Free Flow'); _saveTimerState(); } else { _t.running = true; _t.intervalId = setInterval(_tick, 1000); _updateStartBtn(); _saveTimerState(); } } function timerReset() { _stopInterval(); _t.phase = 'work'; _t.secondsElapsed = 0; _setBreakStyle(false); _setDisplay(); _setPhaseLabel(_t.mode === 'pomodoro' ? '⚔️ Work Session' : '🌊 Free Flow'); _saveTimerState(); } function setTimerMode(mode) { if (_t.running) _stopInterval(); _t.mode = mode; _t.phase = 'work'; _t.secondsElapsed = 0; _setModeButtonActive(mode); _setBreakStyle(false); _setDisplay(); const isFreeflow = mode === 'freeflow'; const workConfig = document.getElementById('work-config'); const breakConfig = document.getElementById('break-config'); if (workConfig) workConfig.style.display = isFreeflow ? 'none' : ''; if (breakConfig) breakConfig.style.display = isFreeflow ? 'none' : ''; _setPhaseLabel(mode === 'pomodoro' ? '⚔️ Work Session' : '🌊 Free Flow'); _saveTimerState(); } // ── LLM-controlled API ──────────────────────────────────────────────────────── function timerStart(durationMinutes, mode) { if (_t.running) _stopInterval(); setTimerMode(mode || 'pomodoro'); if ((mode || 'pomodoro') === 'pomodoro' && durationMinutes) { const el = document.getElementById('work-dur'); if (el) el.value = durationMinutes; } _t.running = true; _t.intervalId = setInterval(_tick, 1000); _updateStartBtn(); } function timerStop() { if (_t.running) _stopInterval(); } function timerAddMinutes(mins) { _t.secondsElapsed = Math.max(0, _t.secondsElapsed + mins * 60); _setDisplay(); _saveTimerState(); } // Export public API for inline onclick handlers window.timerToggle = timerToggle; window.timerReset = timerReset; window.setTimerMode = setTimerMode; window.timerStart = timerStart; window.timerStop = timerStop; window.timerAddMinutes = timerAddMinutes; // ── Init ────────────────────────────────────────────────────────────────────── function _initTimer() { if (!document.getElementById('timer-elapsed')) { setTimeout(_initTimer, 150); return; } _restoreTimerState(); _setDisplay(); _setModeButtonActive(_t.mode); _setBreakStyle(_t.phase === 'break'); _setPhaseLabel(_t.mode === 'freeflow' ? '🌊 Free Flow' : _t.phase === 'break' ? '☕ Break Time' : '⚔️ Work Session'); const isFreeflow = _t.mode === 'freeflow'; const workConfig = document.getElementById('work-config'); const breakConfig = document.getElementById('break-config'); if (workConfig) workConfig.style.display = isFreeflow ? 'none' : ''; if (breakConfig) breakConfig.style.display = isFreeflow ? 'none' : ''; _updateStartBtn(); } _initTimer();