hallo knut
This commit is contained in:
commit
33a8b18147
79 files changed
+2267
No files matched your search
@@ -0,0 +1,93 @@
|
||||
# Interval Timer
|
||||
|
||||
A mobile-first interval training timer that installs as a Progressive Web App.
|
||||
No build step, no dependencies, no network calls — just static files.
|
||||
|
||||
## The cycle
|
||||
|
||||
For every set, each exercise gets a **pause** followed by a **training** block:
|
||||
|
||||
```
|
||||
for each set:
|
||||
for each exercise:
|
||||
pause (default 15s) text is grey
|
||||
training (default 45s) text turns red
|
||||
```
|
||||
|
||||
- **Start** rings a bell, shows the first exercise in grey and runs the pause.
|
||||
- When the pause ends a beep plays and the text turns **red** for the training block.
|
||||
- When training ends another beep plays, the next exercise appears and its pause begins.
|
||||
- After the last exercise of the last set, a triple bell rings and the screen shows *Workout complete*.
|
||||
|
||||
`Pause` / `Resume` freezes the clock; `Reset` returns to the start. The screen is kept
|
||||
awake during a session via the Screen Wake Lock API.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `index.html` | Markup: stage, controls, settings |
|
||||
| `styles.css` | Dark theme; grey/red phase colours |
|
||||
| `app.js` | Timer state machine, Web Audio bell/beep, exercise list |
|
||||
| `manifest.webmanifest` | PWA metadata and icons |
|
||||
| `sw.js` | Service worker — app shell cached for offline use |
|
||||
| `icons/` | Generated app icons |
|
||||
| `tools/make_icons.py` | Regenerates the icons (needs Pillow) |
|
||||
| `tools/smoke_test.mjs` | Headless-Chrome test of the whole cycle |
|
||||
| `start` | Your local live-server launcher (port 8080) |
|
||||
|
||||
Settings and the exercise list are stored in `localStorage`, so they survive a reload.
|
||||
|
||||
## Running it on your phone
|
||||
|
||||
PWA installation and offline caching require a **secure context** (HTTPS, or
|
||||
`localhost`). A plain `http://<laptop-ip>:8080` URL works as a normal web page but
|
||||
will not be installable and will not cache offline.
|
||||
|
||||
### 1. Serve the files
|
||||
|
||||
```sh
|
||||
cd timer
|
||||
python3 -m http.server 8080
|
||||
# or, using the ./start script (Node + live-server, auto-reload)
|
||||
./start
|
||||
```
|
||||
|
||||
### 2. Give your phone an HTTPS URL
|
||||
|
||||
Either expose the local server through a tunnel, for example:
|
||||
|
||||
```sh
|
||||
cloudflared tunnel --url http://localhost:8080
|
||||
# or: npx localtunnel --port 8080
|
||||
```
|
||||
|
||||
or upload the folder as-is to any static host (GitHub Pages, Netlify, Cloudflare
|
||||
Pages, Vercel). There is nothing to compile.
|
||||
|
||||
### 3. Install
|
||||
|
||||
Open the HTTPS URL on the phone and use **Add to Home Screen** (Safari: Share
|
||||
menu; Chrome: banner or the ⋮ menu → *Install app*). It then launches fullscreen
|
||||
and works offline.
|
||||
|
||||
### Audio notes
|
||||
|
||||
- The bell and beeps are synthesised with the Web Audio API, so there are no sound
|
||||
files to download and they work offline.
|
||||
- Audio is unlocked by the first tap on **Start**, as mobile browsers require.
|
||||
- On iOS the ringer/silent switch still mutes Web Audio playback — turn silent mode
|
||||
off for a workout.
|
||||
|
||||
## Tests
|
||||
|
||||
With a server on `127.0.0.1:8765` and Chrome running with `--remote-debugging-port=9222`:
|
||||
|
||||
```sh
|
||||
cd timer
|
||||
node tools/smoke_test.mjs
|
||||
```
|
||||
|
||||
The test drives the real page and asserts the exact pause/training sequence for
|
||||
2 sets × 3 exercises, the grey→red colour switch, phase durations, the frozen clock
|
||||
while paused, reset/restart behaviour and settings persistence.
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
+81
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0e0e11" />
|
||||
<meta name="description" content="Interval training timer with pause and work phases." />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Timer" />
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||
<link rel="icon" href="./icons/icon-192.png" />
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
<title>Interval Timer</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<h1>Interval Timer</h1>
|
||||
<button id="settingsToggle" class="icon-btn" type="button" aria-expanded="false" aria-controls="settings" aria-label="Settings">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
|
||||
<path fill="currentColor" d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0 6a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z"/>
|
||||
<path fill="currentColor" d="m19.4 13-.2-1 .2-1 1.6-1.2-1.6-2.8-1.9.7-1.7-1-.3-2h-3.2l-.3 2-1.7 1-1.9-.7-1.6 2.8L8.8 11l-.2 1 .2 1-1.6 1.2 1.6 2.8 1.9-.7 1.7 1 .3 2h3.2l.3-2 1.7-1 1.9.7 1.6-2.8L19.4 13Zm-2.1 3.2-1.3-.5-2.6 1.5-.2 1.4h-1.6l-.2-1.4-2.6-1.5-1.3.5-.8-1.4 1.1-.9v-3l-1.1-.9.8-1.4 1.3.5 2.6-1.5.2-1.4h1.6l.2 1.4 2.6 1.5 1.3-.5.8 1.4-1.1.9v3l1.1.9-.8 1.4Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="stage" id="stage">
|
||||
<div class="progress" id="progress"></div>
|
||||
|
||||
<div class="phase-label" id="phaseLabel">Ready</div>
|
||||
|
||||
<div class="exercise" id="exerciseName">Add your exercises</div>
|
||||
|
||||
<div class="clock" id="clock">00:00</div>
|
||||
|
||||
<div class="next" id="nextUp"></div>
|
||||
</main>
|
||||
|
||||
<div class="controls" id="controls">
|
||||
<button id="startBtn" class="btn btn-primary" type="button">Start</button>
|
||||
<button id="pauseBtn" class="btn" type="button" hidden>Pause</button>
|
||||
<button id="resetBtn" class="btn" type="button" hidden>Reset</button>
|
||||
</div>
|
||||
|
||||
<section class="settings" id="settings" hidden>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Pause (seconds)</span>
|
||||
<input id="pauseInput" type="number" inputmode="numeric" min="0" step="1" value="15" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Training (seconds)</span>
|
||||
<input id="workInput" type="number" inputmode="numeric" min="1" step="1" value="45" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Sets</span>
|
||||
<input id="setsInput" type="number" inputmode="numeric" min="1" step="1" value="5" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Exercises</h2>
|
||||
<ul class="exercise-list" id="exerciseList"></ul>
|
||||
<p class="empty-hint" id="emptyHint">No exercises yet. Add one below.</p>
|
||||
|
||||
<div class="add-row">
|
||||
<input id="exerciseInput" type="text" placeholder="Exercise name" autocomplete="off" enterkeyhint="done" />
|
||||
<button id="addBtn" class="btn btn-add" type="button" aria-label="Add exercise">+</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">Order: for every set, each exercise runs a pause first, then the training period.</p>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<span id="installHint"></span>
|
||||
</footer>
|
||||
|
||||
<script src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "Interval Timer",
|
||||
"short_name": "Timer",
|
||||
"description": "Interval training timer: pause, training, sets and exercises.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#0e0e11",
|
||||
"theme_color": "#0e0e11",
|
||||
"categories": ["health", "fitness", "sports"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "./icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "./icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Simple static file server with live reload
|
||||
# Requires Node.js and live-server
|
||||
|
||||
# Check if live-server is installed
|
||||
if ! command -v live-server &> /dev/null
|
||||
then
|
||||
echo "Installing live-server..."
|
||||
npm install -g live-server
|
||||
fi
|
||||
|
||||
# Serve the current directory with auto-reload
|
||||
echo "Starting live-server on http://localhost:8080 ..."
|
||||
live-server .
|
||||
|
||||
|
||||
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
:root {
|
||||
--bg: #0e0e11;
|
||||
--bg-soft: #17171c;
|
||||
--bg-input: #202027;
|
||||
--line: #2c2c35;
|
||||
--text: #f2f2f5;
|
||||
--muted: #8b8b96;
|
||||
--pause: #8b8b96;
|
||||
--work: #ff3b30;
|
||||
--accent: #4c8dff;
|
||||
--radius: 14px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* ---------- top bar ---------- */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px 6px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn:active { background: var(--bg-soft); }
|
||||
.icon-btn[aria-expanded="true"] { color: var(--text); }
|
||||
|
||||
/* ---------- stage ---------- */
|
||||
|
||||
.stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 8px 20px;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.phase-label {
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.exercise {
|
||||
font-size: clamp(1.8rem, 9vw, 3.2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
color: var(--pause);
|
||||
transition: color 0.2s ease;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-size: clamp(3.4rem, 22vw, 7rem);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--pause);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.next {
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
min-height: 1.3em;
|
||||
}
|
||||
|
||||
/* phase colours: grey while pausing, red while training */
|
||||
|
||||
body.phase-work .exercise,
|
||||
body.phase-work .clock,
|
||||
body.phase-work .phase-label {
|
||||
color: var(--work);
|
||||
}
|
||||
|
||||
body.phase-done .exercise,
|
||||
body.phase-done .clock,
|
||||
body.phase-done .phase-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
body.phase-paused .clock { opacity: 0.45; }
|
||||
|
||||
/* ---------- controls ---------- */
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 10px 18px 18px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
appearance: none;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-soft);
|
||||
color: var(--text);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
padding: 16px 18px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.btn:active { transform: scale(0.985); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ---------- settings ---------- */
|
||||
|
||||
.settings {
|
||||
background: var(--bg-soft);
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 18px;
|
||||
max-height: 62dvh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 1.05rem;
|
||||
padding: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 22px 0 8px;
|
||||
}
|
||||
|
||||
.exercise-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exercise-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px 10px 10px 14px;
|
||||
}
|
||||
|
||||
.exercise-list .idx {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.4em;
|
||||
}
|
||||
|
||||
.exercise-list .name {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove-btn:active { background: #2c2c35; color: var(--work); }
|
||||
|
||||
.empty-hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.add-row input { flex: 1; }
|
||||
|
||||
.btn-add {
|
||||
flex: 0 0 auto;
|
||||
width: 56px;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1;
|
||||
padding: 8px;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
|
||||
.footer {
|
||||
padding: 0 18px 10px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.footer .btn {
|
||||
flex: none;
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* keep the clock readable on short landscape screens */
|
||||
@media (max-height: 520px) {
|
||||
.clock { font-size: clamp(2.4rem, 14vh, 4rem); }
|
||||
.exercise { font-size: clamp(1.3rem, 5vh, 2rem); }
|
||||
.stage { gap: 2px; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Offline app shell for the Interval Timer PWA. */
|
||||
|
||||
const CACHE = 'interval-timer-v1';
|
||||
|
||||
const ASSETS = [
|
||||
'./',
|
||||
'./index.html',
|
||||
'./styles.css',
|
||||
'./app.js',
|
||||
'./manifest.webmanifest',
|
||||
'./icons/icon-192.png',
|
||||
'./icons/icon-512.png',
|
||||
'./icons/icon-maskable-512.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE)
|
||||
.then((cache) => cache.addAll(ASSETS))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET' || new URL(request.url).origin !== self.location.origin) return;
|
||||
|
||||
// Navigations: try the network, fall back to the cached shell when offline.
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE).then((cache) => cache.put('./index.html', copy));
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match('./index.html'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else: cache first, refresh in the background.
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const network = fetch(request)
|
||||
.then((response) => {
|
||||
if (response && response.status === 200 && response.type === 'basic') {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE).then((cache) => cache.put(request, copy));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached || network;
|
||||
})
|
||||
);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Whitespace-only changes.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"schema":6,"addons":[{"id":"langpack-de@firefox.mozilla.org","icons":{"32":"https://addons.mozilla.org/static-server/img/addon-icons/default-32.png","64":"https://addons.mozilla.org/static-server/img/addon-icons/default-64.png","128":"https://addons.mozilla.org/static-server/img/addon-icons/default-128.png"},"name":"Deutsch (DE) Language Pack","version":"157.0.20260918.91356","sourceURI":"https://addons.mozilla.org/firefox/downloads/file/5039501/deutsch_de_language_pack-157.0.20260918.91356.xpi","homepageURL":null,"supportURL":null,"amoListingURL":"https://addons.mozilla.org/de/firefox/addon/deutsch-de-language-pack/","description":"Deutsch (DE) Language Pack","fullDescription":null,"weeklyDownloads":1045,"type":"locale","creator":{"name":"Firefox","url":"https://addons.mozilla.org/de/firefox/user/4757633/"},"developers":[],"screenshots":[],"contributionURL":"","averageRating":4.1419,"reviewCount":280,"reviewURL":"https://addons.mozilla.org/de/firefox/addon/deutsch-de-language-pack/reviews/","updateDate":1789737511000}]}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,532 @@
|
||||
(() => {
|
||||
'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);
|
||||
});
|
||||
})();
|
||||
3Ì
|
||||
ekQ | ||||