Coach — Workout Timer
An energetic HIIT interval timer with configurable work, rest and round counts, a giant color-shifting countdown, phase-aware background flashes, visual beep and vibration cues, plus start, pause and reset controls with live round progress dots and a total-time estimate.
MCP
Code
:root {
--bg: #0f1115;
--surface: #171a21;
--surface-2: #1f232c;
--lime: #c2f542;
--lime-d: #a3d62f;
--orange: #ff6b35;
--ink: #f2f4f0;
--muted: #9aa0a6;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--white: #fff;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100%;
}
body {
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1000px 600px at 85% -10%, rgba(194, 245, 66, 0.10), transparent 60%),
radial-gradient(900px 600px at -5% 110%, rgba(255, 107, 53, 0.10), transparent 55%),
var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
display: flex;
justify-content: center;
padding: 28px 18px 56px;
}
.app {
width: 100%;
max-width: 480px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* Topbar */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 22px;
border-radius: var(--r-md);
background: linear-gradient(145deg, var(--lime), var(--lime-d));
color: #10130a;
box-shadow: 0 8px 26px rgba(194, 245, 66, 0.28);
}
.brand-kicker {
margin: 0;
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.brand-title {
margin: 0;
font-size: 20px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.badge {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--line-2);
color: var(--muted);
background: var(--surface);
white-space: nowrap;
}
.badge-live {
color: #10130a;
background: var(--lime);
border-color: transparent;
}
/* Stage */
.stage {
position: relative;
overflow: hidden;
border-radius: var(--r-lg);
border: 1px solid var(--line);
background:
radial-gradient(600px 320px at 50% -20%, var(--phase-glow, rgba(194, 245, 66, 0.14)), transparent 65%),
var(--surface);
padding: 30px 22px 26px;
transition: box-shadow 0.35s ease, border-color 0.35s ease;
--phase-glow: rgba(194, 245, 66, 0.14);
--phase: var(--lime);
}
.stage[data-phase="work"] { --phase: var(--lime); --phase-glow: rgba(194, 245, 66, 0.22); border-color: rgba(194, 245, 66, 0.35); }
.stage[data-phase="rest"] { --phase: var(--orange); --phase-glow: rgba(255, 107, 53, 0.22); border-color: rgba(255, 107, 53, 0.35); }
.stage[data-phase="ready"] { --phase: var(--lime); }
.stage[data-phase="done"] { --phase: var(--lime); --phase-glow: rgba(194, 245, 66, 0.3); }
.stage-inner {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
}
.phase-label {
margin: 0;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.24em;
text-transform: uppercase;
color: var(--phase);
text-shadow: 0 0 22px color-mix(in srgb, var(--phase) 45%, transparent);
}
.ring-wrap { display: grid; place-items: center; }
.ring {
width: 240px;
height: 240px;
border-radius: 50%;
display: grid;
place-items: center;
background:
conic-gradient(var(--phase) calc(var(--pct) * 1%), rgba(255, 255, 255, 0.07) 0);
transition: background 0.25s linear;
position: relative;
}
.ring::after {
content: "";
position: absolute;
inset: 12px;
border-radius: 50%;
border: 1px solid var(--line);
box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.ring-core {
width: 196px;
height: 196px;
border-radius: 50%;
background: radial-gradient(circle at 50% 35%, var(--surface-2), var(--surface));
display: grid;
place-items: center;
align-content: center;
text-align: center;
z-index: 1;
}
.count {
margin: 0;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 76px;
font-weight: 800;
line-height: 1;
letter-spacing: -0.02em;
color: var(--white);
font-variant-numeric: tabular-nums;
}
.count-sub {
margin: 6px 0 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.round-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.chip-round {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
padding: 6px 14px;
border-radius: 999px;
}
.dots {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.dots li {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.10);
transition: background 0.25s ease, transform 0.25s ease, box-shadow 0.25s ease;
}
.dots li.done {
background: var(--phase);
}
.dots li.active {
background: var(--phase);
transform: scale(1.25);
box-shadow: 0 0 14px color-mix(in srgb, var(--phase) 60%, transparent);
}
/* Flash overlay */
.flash {
position: absolute;
inset: 0;
z-index: 1;
background: var(--phase);
opacity: 0;
pointer-events: none;
}
.flash.pulse {
animation: flashPulse 0.9s ease-out;
}
@keyframes flashPulse {
0% { opacity: 0.32; }
100% { opacity: 0; }
}
/* Controls */
.controls {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 12px;
}
.btn {
font-family: inherit;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 16px 18px;
border-radius: var(--r-md);
border: 1px solid transparent;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.btn:focus-visible {
outline: 3px solid var(--lime);
outline-offset: 3px;
}
.btn-primary {
color: #10130a;
background: linear-gradient(145deg, var(--lime), var(--lime-d));
box-shadow: 0 10px 30px rgba(194, 245, 66, 0.32);
}
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 14px 38px rgba(194, 245, 66, 0.42); }
.btn-primary:active { transform: translateY(0) scale(0.99); }
.btn-primary.running {
background: linear-gradient(145deg, var(--orange), #e2551f);
box-shadow: 0 10px 30px rgba(255, 107, 53, 0.34);
color: var(--white);
}
.btn-ghost {
color: var(--ink);
background: var(--surface);
border-color: var(--line-2);
}
.btn-ghost:hover { border-color: var(--lime); color: var(--lime); transform: translateY(-2px); }
.btn-ghost:active { transform: translateY(0); }
/* Setup */
.setup {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 20px 18px 22px;
display: flex;
flex-direction: column;
gap: 18px;
transition: opacity 0.25s ease;
}
.setup.locked { opacity: 0.45; pointer-events: none; }
.setup-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.setup-head h2 {
margin: 0;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.est {
font-size: 12px;
font-weight: 700;
color: var(--lime);
background: rgba(194, 245, 66, 0.10);
border: 1px solid rgba(194, 245, 66, 0.25);
padding: 5px 12px;
border-radius: 999px;
font-variant-numeric: tabular-nums;
}
.presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preset {
font-family: inherit;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
background: var(--surface-2);
border: 1px solid var(--line);
padding: 8px 14px;
border-radius: 999px;
cursor: pointer;
transition: all 0.18s ease;
}
.preset:hover { color: var(--ink); border-color: var(--line-2); transform: translateY(-1px); }
.preset.active { color: #10130a; background: var(--lime); border-color: transparent; }
.preset:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
.steppers {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.stepper {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px 10px;
text-align: center;
}
.stepper-label {
margin: 0 0 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.stepper-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.step {
width: 30px;
height: 30px;
flex: none;
display: grid;
place-items: center;
font-size: 18px;
font-weight: 700;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
cursor: pointer;
transition: all 0.15s ease;
}
.step:hover { border-color: var(--lime); color: var(--lime); }
.step:active { transform: scale(0.92); }
.step:focus-visible { outline: 3px solid var(--lime); outline-offset: 2px; }
.step-val {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 22px;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--white);
}
.step-val small {
font-size: 12px;
color: var(--muted);
font-weight: 600;
}
/* Toast */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(140%);
background: var(--surface-2);
color: var(--ink);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 12px 18px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
opacity: 0;
transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.32s ease;
z-index: 40;
max-width: 88vw;
}
.toast.show { transform: translateX(-50%) translateY(0); opacity: 1; }
@media (max-width: 520px) {
body { padding: 20px 14px 48px; }
.ring { width: 208px; height: 208px; }
.ring-core { width: 168px; height: 168px; }
.count { font-size: 62px; }
.brand-title { font-size: 18px; }
.steppers { gap: 8px; }
.stepper { padding: 10px 6px; }
.step { width: 28px; height: 28px; }
.step-val { font-size: 19px; }
}(function () {
"use strict";
// ---- Config limits ----
var LIMITS = {
work: { min: 5, max: 300, stepDelta: 5 },
rest: { min: 0, max: 300, stepDelta: 5 },
rounds: { min: 1, max: 30, stepDelta: 1 }
};
var config = { work: 20, rest: 10, rounds: 8 };
// ---- State ----
var state = {
running: false,
phase: "ready", // ready | work | rest | done
round: 0, // 1-based during a session
remaining: 0,
total: 0,
ticker: null,
lastFlashAt: -1
};
// ---- Elements ----
var $ = function (id) { return document.getElementById(id); };
var stage = $("stage");
var phaseLabel = $("phaseLabel");
var countEl = $("count");
var countSub = $("countSub");
var ring = $("ring");
var roundChip = $("roundChip");
var dotsEl = $("dots");
var flash = $("flash");
var statusBadge = $("statusBadge");
var startBtn = $("startBtn");
var resetBtn = $("resetBtn");
var setup = $("setup");
var estimate = $("estimate");
var toastEl = $("toast");
var presets = Array.prototype.slice.call(document.querySelectorAll(".preset"));
var stepperEls = Array.prototype.slice.call(document.querySelectorAll(".stepper"));
var valEls = {
work: $("workVal"),
rest: $("restVal"),
rounds: $("roundsVal")
};
// ---- Helpers ----
var toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
function fmt(sec) {
var m = Math.floor(sec / 60);
var s = sec % 60;
return m + ":" + (s < 10 ? "0" + s : s);
}
function pad2(n) {
return n < 10 ? "0" + n : "" + n;
}
function totalSeconds() {
return config.rounds * config.work + Math.max(0, config.rounds - 1) * config.rest;
}
function buzz(pattern) {
if (navigator.vibrate) {
try { navigator.vibrate(pattern); } catch (e) { /* ignore */ }
}
}
function pulseFlash() {
flash.classList.remove("pulse");
// force reflow so the animation can restart
void flash.offsetWidth;
flash.classList.add("pulse");
}
// ---- Rendering ----
function renderConfig() {
valEls.work.innerHTML = config.work + "<small>s</small>";
valEls.rest.innerHTML = config.rest + "<small>s</small>";
valEls.rounds.textContent = config.rounds;
estimate.textContent = "~" + fmt(totalSeconds()) + " total";
renderDots();
updatePresetMatch();
if (!state.running && state.phase === "ready") {
roundChip.textContent = "Round 0 / " + config.rounds;
}
}
function renderDots() {
dotsEl.innerHTML = "";
for (var i = 1; i <= config.rounds; i++) {
var li = document.createElement("li");
if (state.phase !== "ready") {
if (i < state.round) li.className = "done";
else if (i === state.round) li.className = "active";
}
dotsEl.appendChild(li);
}
}
function updatePresetMatch() {
presets.forEach(function (p) {
var match =
parseInt(p.dataset.work, 10) === config.work &&
parseInt(p.dataset.rest, 10) === config.rest &&
parseInt(p.dataset.rounds, 10) === config.rounds;
p.classList.toggle("active", match);
});
}
function paintStage() {
stage.dataset.phase = state.phase;
var pct;
if (state.total > 0) {
pct = ((state.total - state.remaining) / state.total) * 100;
} else {
pct = 0;
}
ring.style.setProperty("--pct", pct.toFixed(2));
countEl.textContent = pad2(state.remaining);
if (state.phase === "work") {
phaseLabel.textContent = "Work";
countSub.textContent = "Push hard";
roundChip.textContent = "Round " + state.round + " / " + config.rounds;
} else if (state.phase === "rest") {
phaseLabel.textContent = "Rest";
countSub.textContent = "Breathe";
roundChip.textContent = "Round " + state.round + " / " + config.rounds;
} else if (state.phase === "ready") {
phaseLabel.textContent = "Get Ready";
countSub.textContent = state.running ? "Starting" : "Press start";
} else if (state.phase === "done") {
phaseLabel.textContent = "Complete";
countSub.textContent = "Nice work";
roundChip.textContent = config.rounds + " rounds done";
}
renderDots();
}
// ---- Phase transitions ----
function beginPhase(phase, round) {
state.phase = phase;
if (round) state.round = round;
state.total = phase === "work" ? config.work : config.rest;
state.remaining = state.total;
state.lastFlashAt = -1;
paintStage();
}
function advance() {
// Called when a phase's remaining hits 0
if (state.phase === "ready") {
beginPhase("work", 1);
buzz(60);
return;
}
if (state.phase === "work") {
if (config.rest > 0 && state.round < config.rounds) {
beginPhase("rest");
buzz(80);
} else if (state.round < config.rounds) {
beginPhase("work", state.round + 1);
buzz(60);
} else {
finish();
}
return;
}
if (state.phase === "rest") {
beginPhase("work", state.round + 1);
buzz(60);
return;
}
}
function tick() {
state.remaining -= 1;
// Cue on final 3 seconds of a live phase
if (
(state.phase === "work" || state.phase === "rest") &&
state.remaining <= 3 &&
state.remaining >= 1 &&
state.remaining !== state.lastFlashAt
) {
state.lastFlashAt = state.remaining;
pulseFlash();
buzz(40);
}
if (state.remaining <= 0) {
advance();
} else {
paintStage();
}
}
// ---- Controls ----
function start() {
if (state.running) return;
if (state.phase === "done") reset(true);
state.running = true;
startBtn.textContent = "Pause";
startBtn.classList.add("running");
statusBadge.textContent = "Live";
setup.classList.add("locked");
if (state.phase === "ready" && state.remaining <= 0) {
// Kick off the first work interval immediately
beginPhase("work", 1);
buzz(60);
toast("Let's go — " + config.rounds + " rounds");
} else {
toast("Resumed");
}
paintStage();
state.ticker = setInterval(tick, 1000);
}
function pause() {
if (!state.running) return;
state.running = false;
clearInterval(state.ticker);
state.ticker = null;
startBtn.textContent = "Resume";
startBtn.classList.remove("running");
statusBadge.textContent = "Paused";
toast("Paused");
}
function toggle() {
if (state.running) pause();
else start();
}
function finish() {
clearInterval(state.ticker);
state.ticker = null;
state.running = false;
state.phase = "done";
state.remaining = 0;
state.total = 0;
startBtn.textContent = "Start";
startBtn.classList.remove("running");
statusBadge.textContent = "Done";
setup.classList.remove("locked");
pulseFlash();
buzz([120, 60, 120, 60, 200]);
countEl.textContent = "00";
ring.style.setProperty("--pct", "100");
paintStage();
toast("Session complete — great work!");
}
function reset(silent) {
clearInterval(state.ticker);
state.ticker = null;
state.running = false;
state.phase = "ready";
state.round = 0;
state.remaining = 0;
state.total = 0;
state.lastFlashAt = -1;
startBtn.textContent = "Start";
startBtn.classList.remove("running");
statusBadge.textContent = "Ready";
setup.classList.remove("locked");
ring.style.setProperty("--pct", "0");
countEl.textContent = "00";
paintStage();
if (!silent) toast("Reset to plan");
}
// ---- Setup interactions ----
function changeField(field, dir) {
if (state.running) return;
var lim = LIMITS[field];
var next = config[field] + dir * lim.stepDelta;
next = Math.max(lim.min, Math.min(lim.max, next));
config[field] = next;
renderConfig();
}
stepperEls.forEach(function (st) {
var field = st.dataset.field;
st.querySelectorAll(".step").forEach(function (b) {
b.addEventListener("click", function () {
changeField(field, parseInt(b.dataset.dir, 10));
});
});
});
presets.forEach(function (p) {
p.addEventListener("click", function () {
if (state.running) {
toast("Pause before changing preset");
return;
}
config.work = parseInt(p.dataset.work, 10);
config.rest = parseInt(p.dataset.rest, 10);
config.rounds = parseInt(p.dataset.rounds, 10);
reset(true);
renderConfig();
toast(p.textContent + " loaded");
});
});
startBtn.addEventListener("click", toggle);
resetBtn.addEventListener("click", function () { reset(false); });
document.addEventListener("keydown", function (e) {
if (e.code === "Space" && e.target === document.body) {
e.preventDefault();
toggle();
} else if (e.key === "r" || e.key === "R") {
if (e.target === document.body) reset(false);
}
});
// ---- Init ----
renderConfig();
paintStage();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Coach — Workout Timer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">⚡</span>
<div>
<p class="brand-kicker">Stealthis Coach</p>
<h1 class="brand-title">Interval Timer</h1>
</div>
</div>
<span class="badge badge-live" id="statusBadge">Ready</span>
</header>
<section class="stage" id="stage" aria-live="polite">
<div class="stage-inner">
<p class="phase-label" id="phaseLabel">Get Ready</p>
<div class="ring-wrap">
<div class="ring" id="ring" style="--pct:0">
<div class="ring-core">
<p class="count" id="count">00</p>
<p class="count-sub" id="countSub">Press start</p>
</div>
</div>
</div>
<div class="round-meta">
<span class="chip-round" id="roundChip">Round 0 / 8</span>
<ol class="dots" id="dots" aria-label="Round progress"></ol>
</div>
</div>
<div class="flash" id="flash" aria-hidden="true"></div>
</section>
<section class="controls" aria-label="Timer controls">
<button class="btn btn-primary" id="startBtn" type="button">Start</button>
<button class="btn btn-ghost" id="resetBtn" type="button">Reset</button>
</section>
<section class="setup" id="setup" aria-label="Session setup">
<div class="setup-head">
<h2>Build your circuit</h2>
<span class="est" id="estimate">~4:00 total</span>
</div>
<div class="presets" role="group" aria-label="Presets">
<button class="preset" type="button" data-work="20" data-rest="10" data-rounds="8">Tabata</button>
<button class="preset" type="button" data-work="60" data-rest="0" data-rounds="10">EMOM</button>
<button class="preset" type="button" data-work="40" data-rest="20" data-rounds="6">Classic HIIT</button>
<button class="preset" type="button" data-work="30" data-rest="30" data-rounds="8">Beginner</button>
</div>
<div class="steppers">
<div class="stepper" data-field="work">
<p class="stepper-label">Work</p>
<div class="stepper-row">
<button class="step" type="button" data-dir="-1" aria-label="Decrease work">−</button>
<span class="step-val" id="workVal">20<small>s</small></span>
<button class="step" type="button" data-dir="1" aria-label="Increase work">+</button>
</div>
</div>
<div class="stepper" data-field="rest">
<p class="stepper-label">Rest</p>
<div class="stepper-row">
<button class="step" type="button" data-dir="-1" aria-label="Decrease rest">−</button>
<span class="step-val" id="restVal">10<small>s</small></span>
<button class="step" type="button" data-dir="1" aria-label="Increase rest">+</button>
</div>
</div>
<div class="stepper" data-field="rounds">
<p class="stepper-label">Rounds</p>
<div class="stepper-row">
<button class="step" type="button" data-dir="-1" aria-label="Decrease rounds">−</button>
<span class="step-val" id="roundsVal">8</span>
<button class="step" type="button" data-dir="1" aria-label="Increase rounds">+</button>
</div>
</div>
</div>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>A gym-floor-ready interval timer built for high-intensity circuits. The setup panel lets athletes dial in work seconds, rest seconds and total rounds with plus and minus steppers, while a live estimate shows the full session length before the first whistle. Preset chips (Tabata, EMOM, Classic HIIT) load battle-tested schemes in one tap.
The stage takes over during a session: a huge monospaced countdown, an animated conic progress ring and a phase label that swaps between GET READY, WORK and REST. The whole surface pulses lime for work and orange for rest, and the last three seconds of any phase trigger a bright flash plus a navigator.vibrate buzz so lifters can keep their eyes on the bar. A row of round dots fills in as the circuit advances.
Controls stay glove-friendly: a glowing lime START toggles to PAUSE, RESET returns to the configured plan, and everything is keyboard reachable with visible focus rings. Space bar starts and pauses, and a toast confirms preset changes and session completion.