hallo knut

This commit is contained in:
oliver committed 2026-09-19 07:28:18 -03:00
commit 33a8b18147
79 files changed
+2267

No files matched your search

+519
View File
@@ -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);
});
})();