Spaces:
Running on Zero
Running on Zero
| // ββ 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(); | |