(() => { 'use strict'; const STORAGE_KEY = 'interval-timer/v1'; const TICK_MS = 100; const els = { body: document.body, stage: document.getElementById('stage'), progress: document.getElementById('progress'), phaseLabel: document.getElementById('phaseLabel'), exerciseName: document.getElementById('exerciseName'), clock: document.getElementById('clock'), nextUp: document.getElementById('nextUp'), startBtn: document.getElementById('startBtn'), pauseBtn: document.getElementById('pauseBtn'), resetBtn: document.getElementById('resetBtn'), settingsToggle: document.getElementById('settingsToggle'), settings: document.getElementById('settings'), pauseInput: document.getElementById('pauseInput'), workInput: document.getElementById('workInput'), setsInput: document.getElementById('setsInput'), exerciseList: document.getElementById('exerciseList'), emptyHint: document.getElementById('emptyHint'), exerciseInput: document.getElementById('exerciseInput'), addBtn: document.getElementById('addBtn'), installHint: document.getElementById('installHint'), }; const DEFAULTS = { pauseSec: 15, workSec: 45, sets: 5, exercises: [] }; /* ------------------------------------------------------------------ * * settings persistence * ------------------------------------------------------------------ */ function loadSettings() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return { ...DEFAULTS }; const parsed = JSON.parse(raw); return { pauseSec: clampInt(parsed.pauseSec, 0, 3600, DEFAULTS.pauseSec), workSec: clampInt(parsed.workSec, 1, 3600, DEFAULTS.workSec), sets: clampInt(parsed.sets, 1, 999, DEFAULTS.sets), exercises: Array.isArray(parsed.exercises) ? parsed.exercises.filter((e) => typeof e === 'string' && e.trim()).map((e) => e.trim()) : [], }; } catch { return { ...DEFAULTS }; } } function saveSettings() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch { /* storage unavailable (private mode) — the app still works in memory */ } } function clampInt(value, min, max, fallback) { const n = Math.round(Number(value)); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, n)); } const settings = loadSettings(); /* ------------------------------------------------------------------ * * runtime state * ------------------------------------------------------------------ */ const run = { phase: 'idle', // idle | pause | work | done setIndex: 0, exIndex: 0, remaining: 0, deadline: 0, paused: false, pausedRemaining: 0, timerId: null, }; const isActive = () => run.phase === 'pause' || run.phase === 'work'; /* ------------------------------------------------------------------ * * audio — synthesised, so the app needs no sound files and works offline * ------------------------------------------------------------------ */ let audioCtx = null; function ensureAudio() { try { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return null; if (!audioCtx) audioCtx = new Ctx(); if (audioCtx.state === 'suspended') audioCtx.resume().catch(() => {}); return audioCtx; } catch { return null; // audio unavailable — the timer must still run } } function tone(freq, delay, duration, peak, type) { if (!ensureAudio()) return; const ctx = audioCtx; const t0 = ctx.currentTime + delay; const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = type || 'sine'; osc.frequency.setValueAtTime(freq, t0); gain.gain.setValueAtTime(0.0001, t0); gain.gain.exponentialRampToValueAtTime(peak, t0 + 0.012); gain.gain.exponentialRampToValueAtTime(0.0001, t0 + duration); osc.connect(gain).connect(ctx.destination); osc.start(t0); osc.stop(t0 + duration + 0.05); } /** Bell: a few inharmonic partials with long decay. */ function bell(count) { if (!ensureAudio()) return; const rings = count || 1; for (let i = 0; i < rings; i++) { const at = i * 0.55; tone(880, at, 1.6, 0.32, 'sine'); tone(1320, at, 1.1, 0.16, 'sine'); tone(1760, at, 0.7, 0.09, 'sine'); tone(587, at, 1.9, 0.12, 'sine'); } } /** Short beep marking a phase change. Higher = training, lower = pause. */ function beep(freq) { if (!ensureAudio()) return; tone(freq, 0, 0.16, 0.3, 'square'); tone(freq * 2, 0, 0.1, 0.08, 'sine'); } function vibrate(pattern) { if (navigator.vibrate) { try { navigator.vibrate(pattern); } catch { /* ignore */ } } } /* ------------------------------------------------------------------ * * screen wake lock — keeps the phone display on during a session * ------------------------------------------------------------------ */ let wakeLock = null; async function requestWakeLock() { if (!('wakeLock' in navigator)) return; try { wakeLock = await navigator.wakeLock.request('screen'); wakeLock.addEventListener('release', () => { wakeLock = null; }); } catch { /* denied or unsupported — not fatal */ } } function releaseWakeLock() { if (wakeLock) { wakeLock.release().catch(() => {}); wakeLock = null; } } /* ------------------------------------------------------------------ * * timer engine — phases chain off their own deadline, so a throttled or * suspended page catches up to real elapsed time instead of drifting * ------------------------------------------------------------------ */ const MAX_CATCHUP = 5000; function startTimer() { if (run.timerId !== null) return; run.timerId = window.setInterval(tick, TICK_MS); } function stopTimer() { if (run.timerId !== null) { window.clearInterval(run.timerId); run.timerId = null; } } function tick() { if (!isActive() || run.paused) return; const now = performance.now(); const late = now - run.deadline; if (late < 0) { run.remaining = -late / 1000; render(); return; } // One or more phases ended while we were not ticking (timer throttling). // Play the cue only for a prompt transition, not for a bulk catch-up. let steps = 0; while (isActive() && !run.paused && run.deadline <= now && steps < MAX_CATCHUP) { advance(steps === 0 && late < 1500); steps++; } if (!isActive()) return; run.remaining = Math.max(0, (run.deadline - now) / 1000); render(); } /** `from` anchors the new phase to the previous phase's deadline. */ function beginPhase(phase, from) { run.phase = phase; const duration = phase === 'pause' ? settings.pauseSec : settings.workSec; run.remaining = duration; run.deadline = (from === undefined ? performance.now() : from) + duration * 1000; render(); } function advance(playSound) { if (run.phase === 'pause') { // pause finished -> training starts, text turns red if (playSound) { beep(1180); vibrate(140); } beginPhase('work', run.deadline); return; } if (run.phase === 'work') { // training finished -> line up the next exercise const from = run.deadline; run.exIndex += 1; if (run.exIndex >= settings.exercises.length) { run.exIndex = 0; run.setIndex += 1; } if (run.setIndex >= settings.sets) { finish(); return; } if (playSound) { beep(720); vibrate(140); } beginPhase('pause', from); } } function start() { if (settings.exercises.length === 0) { openSettings(); els.exerciseInput.focus(); return; } ensureAudio(); // must happen inside the user gesture run.setIndex = 0; run.exIndex = 0; run.paused = false; run.pausedRemaining = 0; bell(1); vibrate(300); requestWakeLock(); startTimer(); beginPhase('pause'); } function togglePause() { if (!isActive()) return; if (run.paused) { run.paused = false; run.deadline = performance.now() + run.pausedRemaining * 1000; } else { run.paused = true; run.pausedRemaining = Math.max(0, (run.deadline - performance.now()) / 1000); } render(); } function finish() { run.phase = 'done'; run.remaining = 0; run.paused = false; stopTimer(); releaseWakeLock(); bell(3); vibrate([250, 120, 250, 120, 500]); render(); } function reset() { run.phase = 'idle'; run.setIndex = 0; run.exIndex = 0; run.remaining = 0; run.paused = false; run.pausedRemaining = 0; stopTimer(); releaseWakeLock(); render(); } /* ------------------------------------------------------------------ * * rendering * ------------------------------------------------------------------ */ function formatTime(seconds) { const total = Math.max(0, Math.ceil(seconds)); const m = Math.floor(total / 60); const s = total % 60; return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function nextExerciseAfterWork() { let ex = run.exIndex + 1; let set = run.setIndex; if (ex >= settings.exercises.length) { ex = 0; set += 1; } if (set >= settings.sets) return null; return settings.exercises[ex]; } function render() { const total = settings.exercises.length; const current = total ? settings.exercises[run.exIndex] : null; els.body.classList.toggle('phase-work', run.phase === 'work'); els.body.classList.toggle('phase-done', run.phase === 'done'); els.body.classList.toggle('phase-paused', run.paused); if (run.phase === 'idle') { els.progress.textContent = total ? `${settings.sets} set${settings.sets === 1 ? '' : 's'} · ${total} exercise${total === 1 ? '' : 's'}` : ''; els.phaseLabel.textContent = 'Ready'; els.exerciseName.textContent = current || 'Add your exercises'; els.clock.textContent = formatTime(settings.pauseSec); els.nextUp.textContent = total ? `Pause ${settings.pauseSec}s, then ${settings.workSec}s training` : ''; } else if (run.phase === 'done') { els.progress.textContent = 'Complete'; els.phaseLabel.textContent = 'Done'; els.exerciseName.textContent = 'Workout complete'; els.clock.textContent = '00:00'; els.nextUp.textContent = `${settings.sets} set${settings.sets === 1 ? '' : 's'} finished`; } else { els.progress.textContent = `Set ${run.setIndex + 1} / ${settings.sets} · Exercise ${run.exIndex + 1} / ${total}`; els.phaseLabel.textContent = run.paused ? 'Paused' : run.phase === 'work' ? 'Training' : 'Pause'; els.exerciseName.textContent = current || ''; els.clock.textContent = formatTime(run.remaining); if (run.phase === 'pause') { els.nextUp.textContent = `Next: ${settings.workSec}s training`; } else { const next = nextExerciseAfterWork(); els.nextUp.textContent = next ? `Next: ${next}` : 'Last one — finish strong'; } } renderControls(); } function renderControls() { const active = isActive(); els.startBtn.hidden = active; els.startBtn.textContent = run.phase === 'done' ? 'Restart' : 'Start'; els.pauseBtn.hidden = !active; els.pauseBtn.textContent = run.paused ? 'Resume' : 'Pause'; els.resetBtn.hidden = !active && run.phase !== 'done'; const lock = active; els.pauseInput.disabled = lock; els.workInput.disabled = lock; els.setsInput.disabled = lock; els.exerciseInput.disabled = lock; els.addBtn.disabled = lock; els.exerciseList.querySelectorAll('button').forEach((b) => { b.disabled = lock; }); } function renderExerciseList() { els.exerciseList.textContent = ''; settings.exercises.forEach((name, i) => { const li = document.createElement('li'); const idx = document.createElement('span'); idx.className = 'idx'; idx.textContent = `${i + 1}.`; const label = document.createElement('span'); label.className = 'name'; label.textContent = name; const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'remove-btn'; remove.textContent = '\u00d7'; remove.setAttribute('aria-label', `Remove ${name}`); remove.addEventListener('click', () => { settings.exercises.splice(i, 1); saveSettings(); renderExerciseList(); render(); }); li.append(idx, label, remove); els.exerciseList.append(li); }); els.emptyHint.hidden = settings.exercises.length > 0; renderControls(); } /* ------------------------------------------------------------------ * * settings UI * ------------------------------------------------------------------ */ function openSettings() { els.settings.hidden = false; els.settingsToggle.setAttribute('aria-expanded', 'true'); } function toggleSettings() { const open = els.settings.hidden; els.settings.hidden = !open; els.settingsToggle.setAttribute('aria-expanded', String(open)); } function addExercise() { const name = els.exerciseInput.value.trim(); if (!name) return; settings.exercises.push(name); saveSettings(); els.exerciseInput.value = ''; renderExerciseList(); render(); els.exerciseInput.focus(); } function bindNumberInput(input, key, min, max) { input.value = String(settings[key]); input.addEventListener('change', () => { settings[key] = clampInt(input.value, min, max, DEFAULTS[key]); input.value = String(settings[key]); saveSettings(); render(); }); } /* ------------------------------------------------------------------ * * wiring * ------------------------------------------------------------------ */ bindNumberInput(els.pauseInput, 'pauseSec', 0, 3600); bindNumberInput(els.workInput, 'workSec', 1, 3600); bindNumberInput(els.setsInput, 'sets', 1, 999); els.startBtn.addEventListener('click', start); els.pauseBtn.addEventListener('click', togglePause); els.resetBtn.addEventListener('click', reset); els.settingsToggle.addEventListener('click', toggleSettings); els.addBtn.addEventListener('click', addExercise); els.exerciseInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addExercise(); } }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { if (isActive() && !run.paused) tick(); if (isActive() && !wakeLock) requestWakeLock(); } }); // space bar toggles pause on desktop document.addEventListener('keydown', (e) => { if (e.code === 'Space' && isActive() && e.target.tagName !== 'INPUT') { e.preventDefault(); togglePause(); } }); renderExerciseList(); render(); /* ------------------------------------------------------------------ * * PWA: service worker + install prompt * ------------------------------------------------------------------ */ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('./sw.js').catch(() => {}); }); } let deferredPrompt = null; window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn'; btn.textContent = 'Install app'; btn.addEventListener('click', async () => { if (!deferredPrompt) return; deferredPrompt.prompt(); await deferredPrompt.userChoice; deferredPrompt = null; els.installHint.textContent = ''; }); els.installHint.textContent = ''; els.installHint.append(btn); }); })();