520 lines
16 KiB
JavaScript
520 lines
16 KiB
JavaScript
(() => {
|
|
'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);
|
|
});
|
|
})();
|