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 | ||||||