hallo knut
This commit is contained in:
commit
33a8b18147
79 files changed
+2267
No files matched your search
@@ -0,0 +1,519 @@
|
||||
(() => {
|
||||
'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);
|
||||
});
|
||||
})();
|
||||
Reference in new issue
Block a user