focus-buddy / static /js /timer.js
pocanman's picture
add debug time controls and MiniCPM5 LLM support
5578b51
Raw
History Blame Contribute Delete
10.3 kB
// ── 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();