Gym — Equipment Status
A live equipment status board for a performance gym floor. Items are grouped by category — treadmills, squat racks, benches, rowers and cable machines — each with a status pill for free, in use or out of order. In-use units show who has them, elapsed time and an ETA progress bar. Summary counters, a status filter and instant search keep the floor scannable, while a report-issue dialog flips any unit out of order with a reason.
MCP
Код
:root {
--bg: #0d0f12;
--surface: #15181d;
--surface-2: #1d2127;
--elevated: #23282f;
--ink: #f4f6f8;
--ink-2: #c2c8d0;
--muted: #8b929c;
--neon: #c6ff3a;
--neon-d: #a6e016;
--neon-50: rgba(198, 255, 58, 0.12);
--orange: #ff6a2b;
--orange-soft: rgba(255, 106, 43, 0.14);
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow: 0 1px 0 rgba(255,255,255,0.04) inset, 0 12px 30px -12px rgba(0,0,0,0.7);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-image:
radial-gradient(900px 500px at 110% -10%, rgba(198,255,58,0.06), transparent 60%),
radial-gradient(700px 500px at -10% 0%, rgba(255,106,43,0.05), transparent 55%);
background-attachment: fixed;
min-height: 100vh;
}
.app {
max-width: 1080px;
margin: 0 auto;
padding: 28px 20px 64px;
}
/* ---------- Top bar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 26px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.logo {
width: 48px;
height: 48px;
border-radius: var(--r-md);
display: grid;
place-items: center;
font-weight: 900;
font-size: 18px;
letter-spacing: -0.5px;
color: #0d0f12;
background: linear-gradient(135deg, var(--neon), var(--neon-d));
box-shadow: 0 8px 22px -8px rgba(198,255,58,0.6);
}
.eyebrow {
display: block;
text-transform: uppercase;
letter-spacing: 0.16em;
font-size: 11px;
font-weight: 700;
color: var(--muted);
}
.brand-text h1 {
margin: 2px 0 0;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.02em;
}
.live {
display: inline-flex;
align-items: center;
gap: 9px;
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
background: var(--surface);
border: 1px solid var(--line);
padding: 9px 14px;
border-radius: 999px;
}
.live .dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 0 rgba(52,211,153,0.6);
animation: pulse 1.8s infinite;
}
.live .clock {
color: var(--neon);
font-variant-numeric: tabular-nums;
font-weight: 700;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(52,211,153,0.5); }
70% { box-shadow: 0 0 0 7px rgba(52,211,153,0); }
100% { box-shadow: 0 0 0 0 rgba(52,211,153,0); }
}
/* ---------- Summary stats ---------- */
.summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 22px;
}
.stat {
appearance: none;
text-align: left;
cursor: pointer;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 16px 14px;
color: var(--ink);
font-family: inherit;
transition: transform 0.12s ease, border-color 0.15s ease, background 0.15s ease;
position: relative;
overflow: hidden;
}
.stat::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 4px;
background: var(--line-2);
transition: background 0.15s ease;
}
.stat.free::before { background: var(--ok); }
.stat.inuse::before { background: var(--orange); }
.stat.oos::before { background: var(--danger); }
.stat:hover { transform: translateY(-2px); border-color: var(--line-2); }
.stat:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
}
.stat[aria-pressed="true"] {
background: var(--surface-2);
border-color: var(--line-2);
}
.stat-num {
display: block;
font-size: 30px;
font-weight: 900;
letter-spacing: -0.03em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.stat.free .stat-num { color: var(--ok); }
.stat.inuse .stat-num { color: var(--orange); }
.stat.oos .stat-num { color: var(--danger); }
.stat-label {
display: block;
margin-top: 8px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
/* ---------- Toolbar ---------- */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 22px;
flex-wrap: wrap;
}
.search {
position: relative;
flex: 1 1 240px;
min-width: 200px;
}
.search-ico {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: var(--muted);
pointer-events: none;
}
.search input {
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
color: var(--ink);
font-family: inherit;
font-size: 14px;
padding: 12px 14px 12px 42px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.search input::placeholder { color: var(--muted); }
.search input:focus {
outline: none;
border-color: var(--neon);
box-shadow: 0 0 0 3px var(--neon-50);
}
.filters {
display: inline-flex;
gap: 6px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px;
}
.chip {
appearance: none;
cursor: pointer;
border: none;
background: transparent;
color: var(--ink-2);
font-family: inherit;
font-size: 13px;
font-weight: 600;
padding: 8px 14px;
border-radius: 999px;
transition: background 0.15s ease, color 0.15s ease;
white-space: nowrap;
}
.chip:hover { color: var(--ink); }
.chip:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
}
.chip.is-active {
background: var(--neon);
color: #0d0f12;
}
/* ---------- Board ---------- */
.board {
display: flex;
flex-direction: column;
gap: 26px;
}
.group-title {
display: flex;
align-items: baseline;
gap: 10px;
margin: 0 0 12px;
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--ink);
}
.group-title .group-count {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--muted);
}
.group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 15px 15px 14px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 10px;
transition: transform 0.12s ease, border-color 0.15s ease;
}
.card:hover { transform: translateY(-2px); border-color: var(--line-2); }
.card.free { border-left: 3px solid var(--ok); }
.card.inuse { border-left: 3px solid var(--orange); }
.card.oos { border-left: 3px solid var(--danger); }
.card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.card-name {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.01em;
}
.card-sub {
font-size: 12px;
color: var(--muted);
margin-top: 2px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 5px 10px;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.pill::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.pill.free { color: var(--ok); background: rgba(52,211,153,0.13); }
.pill.inuse { color: var(--orange); background: var(--orange-soft); }
.pill.oos { color: var(--danger); background: rgba(248,113,113,0.13); }
.card-meta {
font-size: 12.5px;
color: var(--ink-2);
min-height: 18px;
}
.card-meta strong { color: var(--ink); font-weight: 700; }
.card-meta .oos-reason { color: var(--danger); font-weight: 600; }
/* ETA bar */
.eta {
display: flex;
flex-direction: column;
gap: 6px;
}
.eta-track {
height: 6px;
border-radius: 999px;
background: var(--surface-2);
overflow: hidden;
}
.eta-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--orange), #ffb066);
transition: width 0.6s ease;
}
.eta-label {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--muted);
font-weight: 600;
}
.card-foot {
display: flex;
gap: 8px;
margin-top: 2px;
}
.btn {
appearance: none;
cursor: pointer;
font-family: inherit;
font-weight: 700;
font-size: 13px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
padding: 9px 13px;
background: var(--surface-2);
color: var(--ink);
transition: transform 0.1s ease, background 0.15s ease, border-color 0.15s ease;
}
.btn:hover { transform: translateY(-1px); border-color: var(--line-2); }
.btn:active { transform: translateY(0); }
.btn:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
}
.btn.report { flex: 1; }
.btn.fix {
flex: 1;
background: var(--neon);
color: #0d0f12;
border-color: transparent;
}
.btn.fix:hover { background: var(--neon-d); }
.btn.danger {
background: var(--danger);
color: #1a0a0a;
border-color: transparent;
}
.btn.danger:hover { filter: brightness(1.08); }
.btn.ghost {
background: transparent;
}
.empty {
text-align: center;
color: var(--muted);
font-size: 14px;
padding: 48px 0;
font-weight: 600;
}
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(5,6,8,0.7);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
padding: 20px;
z-index: 50;
animation: fade 0.15s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.modal {
width: 100%;
max-width: 420px;
background: var(--elevated);
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
padding: 24px;
box-shadow: 0 30px 60px -20px rgba(0,0,0,0.8);
animation: rise 0.18s ease;
}
@keyframes rise { from { transform: translateY(10px); opacity: 0; } to { transform: none; opacity: 1; } }
.modal h2 {
margin: 0 0 4px;
font-size: 19px;
font-weight: 800;
}
.modal-sub {
margin: 0 0 18px;
font-size: 13px;
color: var(--muted);
}
.field {
display: block;
margin-bottom: 14px;
}
.field span {
display: block;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-2);
margin-bottom: 6px;
}
.field select,
.field textarea {
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-sm);
color: var(--ink);
font-family: inherit;
font-size: 14px;
padding: 10px 12px;
resize: vertical;
}
.field select:focus,
.field textarea:focus {
outline: none;
border-color: var(--neon);
box-shadow: 0 0 0 3px var(--neon-50);
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 18px;
}
/* ---------- Toasts ---------- */
.toast-wrap {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 60;
width: min(360px, calc(100% - 40px));
}
.toast {
background: var(--elevated);
border: 1px solid var(--line-2);
border-left: 3px solid var(--neon);
border-radius: var(--r-md);
padding: 12px 14px;
font-size: 13px;
font-weight: 600;
color: var(--ink);
box-shadow: 0 16px 36px -14px rgba(0,0,0,0.8);
animation: toastIn 0.22s ease;
}
.toast.warn { border-left-color: var(--danger); }
.toast.ok { border-left-color: var(--ok); }
@keyframes toastIn {
from { transform: translateY(12px); opacity: 0; }
to { transform: none; opacity: 1; }
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.app { padding: 20px 14px 56px; }
.topbar { flex-direction: column; align-items: flex-start; gap: 12px; }
.brand-text h1 { font-size: 22px; }
.summary { grid-template-columns: repeat(2, 1fr); }
.toolbar { flex-direction: column; align-items: stretch; }
.filters { overflow-x: auto; justify-content: flex-start; }
.group-grid { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.modal-backdrop[hidden] {
display: none;
}/* Ironworks Performance — Equipment Status board (vanilla JS) */
(function () {
"use strict";
var STATUS = { FREE: "free", INUSE: "inuse", OOS: "oos" };
var LABEL = { free: "Free", inuse: "In use", oos: "Out of order" };
// Equipment grouped by category. inUse holds elapsed minutes; eta holds total session minutes.
var equipment = [
{ id: "tm-1", group: "Treadmills", name: "Treadmill 01", sub: "Woodway Curve", status: STATUS.INUSE, by: "M. Okafor", elapsed: 14, total: 30 },
{ id: "tm-2", group: "Treadmills", name: "Treadmill 02", sub: "Technogym Skillrun", status: STATUS.FREE },
{ id: "tm-3", group: "Treadmills", name: "Treadmill 03", sub: "Technogym Skillrun", status: STATUS.INUSE, by: "Priya N.", elapsed: 22, total: 25 },
{ id: "tm-4", group: "Treadmills", name: "Treadmill 04", sub: "Woodway 4Front", status: STATUS.OOS, reason: "Belt slipping" },
{ id: "sq-1", group: "Squat Racks", name: "Rack A", sub: "Power rack — platform", status: STATUS.INUSE, by: "Diego R.", elapsed: 9, total: 35 },
{ id: "sq-2", group: "Squat Racks", name: "Rack B", sub: "Power rack — platform", status: STATUS.FREE },
{ id: "sq-3", group: "Squat Racks", name: "Rack C", sub: "Half rack", status: STATUS.FREE },
{ id: "sq-4", group: "Squat Racks", name: "Rack D", sub: "Combo rack", status: STATUS.INUSE, by: "Coach Lena", elapsed: 41, total: 45 },
{ id: "bn-1", group: "Benches", name: "Flat Bench 1", sub: "Olympic flat", status: STATUS.FREE },
{ id: "bn-2", group: "Benches", name: "Incline Bench 1", sub: "Adjustable", status: STATUS.INUSE, by: "T. Alvarez", elapsed: 6, total: 20 },
{ id: "bn-3", group: "Benches", name: "Decline Bench 1", sub: "Olympic decline", status: STATUS.FREE },
{ id: "bn-4", group: "Benches", name: "Flat Bench 2", sub: "Olympic flat", status: STATUS.OOS, reason: "Loose / unsafe" },
{ id: "rw-1", group: "Rowers", name: "Rower 01", sub: "Concept2 RowErg", status: STATUS.INUSE, by: "Sam W.", elapsed: 3, total: 15 },
{ id: "rw-2", group: "Rowers", name: "Rower 02", sub: "Concept2 RowErg", status: STATUS.FREE },
{ id: "rw-3", group: "Rowers", name: "Rower 03", sub: "Concept2 RowErg", status: STATUS.INUSE, by: "Hana K.", elapsed: 18, total: 20 },
{ id: "cb-1", group: "Cable Machines", name: "Dual Cable 1", sub: "Functional trainer", status: STATUS.FREE },
{ id: "cb-2", group: "Cable Machines", name: "Lat Pulldown", sub: "Selectorized", status: STATUS.INUSE, by: "Marco V.", elapsed: 11, total: 18 },
{ id: "cb-3", group: "Cable Machines", name: "Cable Crossover", sub: "Functional trainer", status: STATUS.FREE },
{ id: "cb-4", group: "Cable Machines", name: "Seated Row", sub: "Selectorized", status: STATUS.OOS, reason: "Display not working" }
];
var GROUP_ORDER = ["Treadmills", "Squat Racks", "Benches", "Rowers", "Cable Machines"];
var FIRST_NAMES = ["Aria", "Noah", "Zoe", "Liam", "Mei", "Carlos", "Ines", "Jay", "Tara", "Omar", "Nia", "Ben"];
var state = { filter: "all", query: "" };
var board = document.getElementById("board");
var emptyEl = document.getElementById("empty");
var searchEl = document.getElementById("search");
var toastWrap = document.getElementById("toasts");
// ---------- helpers ----------
function byId(id) { return equipment.filter(function (e) { return e.id === id; })[0]; }
function randomName() {
var f = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
return f + " " + String.fromCharCode(65 + Math.floor(Math.random() * 26)) + ".";
}
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
toastWrap.appendChild(el);
setTimeout(function () {
el.style.transition = "opacity .25s ease, transform .25s ease";
el.style.opacity = "0";
el.style.transform = "translateY(8px)";
setTimeout(function () { el.remove(); }, 260);
}, 3200);
}
function matches(item) {
if (state.filter !== "all" && item.status !== state.filter) return false;
if (state.query) {
var q = state.query.toLowerCase();
var hay = (item.name + " " + item.sub + " " + item.group + " " + (item.by || "") + " " + (item.reason || "")).toLowerCase();
if (hay.indexOf(q) === -1) return false;
}
return true;
}
// ---------- rendering ----------
function updateSummary() {
var c = { all: equipment.length, free: 0, inuse: 0, oos: 0 };
equipment.forEach(function (e) { c[e.status]++; });
document.getElementById("count-all").textContent = c.all;
document.getElementById("count-free").textContent = c.free;
document.getElementById("count-inuse").textContent = c.inuse;
document.getElementById("count-oos").textContent = c.oos;
}
function cardHTML(item) {
var meta = "";
if (item.status === STATUS.FREE) {
meta = '<div class="card-meta">Available now — last cleaned <strong>recently</strong></div>';
} else if (item.status === STATUS.INUSE) {
var remaining = Math.max(0, item.total - item.elapsed);
var pct = Math.min(100, Math.round((item.elapsed / item.total) * 100));
meta =
'<div class="card-meta">In use by <strong>' + item.by + "</strong> · " + item.elapsed + " min</div>" +
'<div class="eta">' +
'<div class="eta-track"><div class="eta-fill" style="width:' + pct + '%"></div></div>' +
'<div class="eta-label"><span>Session</span><span>~' + remaining + " min left</span></div>" +
"</div>";
} else {
meta = '<div class="card-meta"><span class="oos-reason">' + item.reason + "</span> · reported</div>";
}
var foot;
if (item.status === STATUS.OOS) {
foot = '<button class="btn fix" data-act="fix" data-id="' + item.id + '">Mark fixed</button>';
} else {
foot = '<button class="btn report" data-act="report" data-id="' + item.id + '">Report issue</button>';
}
return (
'<article class="card ' + item.status + '">' +
'<div class="card-head">' +
"<div><div class=\"card-name\">" + item.name + "</div><div class=\"card-sub\">" + item.sub + "</div></div>" +
'<span class="pill ' + item.status + '">' + LABEL[item.status] + "</span>" +
"</div>" +
meta +
'<div class="card-foot">' + foot + "</div>" +
"</article>"
);
}
function render() {
var visible = equipment.filter(matches);
board.innerHTML = "";
if (!visible.length) {
emptyEl.hidden = false;
return;
}
emptyEl.hidden = true;
GROUP_ORDER.forEach(function (group) {
var items = visible.filter(function (e) { return e.group === group; });
if (!items.length) return;
var section = document.createElement("section");
var total = equipment.filter(function (e) { return e.group === group; }).length;
var head = document.createElement("h2");
head.className = "group-title";
head.innerHTML = group + ' <span class="group-count">' + items.length + " / " + total + " shown</span>";
section.appendChild(head);
var grid = document.createElement("div");
grid.className = "group-grid";
grid.innerHTML = items.map(cardHTML).join("");
section.appendChild(grid);
board.appendChild(section);
});
}
// ---------- modal (report issue) ----------
var modal = document.getElementById("modal");
var modalSub = document.getElementById("modal-sub");
var reasonEl = document.getElementById("reason");
var notesEl = document.getElementById("notes");
var pendingId = null;
var lastFocused = null;
function openModal(id) {
pendingId = id;
var item = byId(id);
modalSub.textContent = "Mark " + item.name + " (" + item.sub + ") out of order so members and staff are warned.";
notesEl.value = "";
reasonEl.selectedIndex = 0;
lastFocused = document.activeElement;
modal.hidden = false;
reasonEl.focus();
document.addEventListener("keydown", onKey);
}
function closeModal() {
modal.hidden = true;
pendingId = null;
document.removeEventListener("keydown", onKey);
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
function onKey(e) { if (e.key === "Escape") closeModal(); }
document.getElementById("cancel").addEventListener("click", closeModal);
modal.addEventListener("click", function (e) { if (e.target === modal) closeModal(); });
document.getElementById("confirm").addEventListener("click", function () {
if (!pendingId) return;
var item = byId(pendingId);
var reason = reasonEl.value;
if (notesEl.value.trim()) reason += " — " + notesEl.value.trim();
item.status = STATUS.OOS;
item.reason = reason;
delete item.by; delete item.elapsed; delete item.total;
closeModal();
updateSummary();
render();
toast(item.name + " marked out of order", "warn");
});
// ---------- board interactions ----------
board.addEventListener("click", function (e) {
var btn = e.target.closest("button[data-act]");
if (!btn) return;
var id = btn.getAttribute("data-id");
var act = btn.getAttribute("data-act");
if (act === "report") {
openModal(id);
} else if (act === "fix") {
var item = byId(id);
item.status = STATUS.FREE;
delete item.reason;
updateSummary();
render();
toast(item.name + " is back in service", "ok");
}
});
// ---------- filters & search ----------
function setFilter(f) {
state.filter = f;
document.querySelectorAll(".chip").forEach(function (c) {
c.classList.toggle("is-active", c.getAttribute("data-filter") === f);
});
document.querySelectorAll(".stat").forEach(function (s) {
s.setAttribute("aria-pressed", String(s.getAttribute("data-filter") === f));
});
render();
}
document.querySelectorAll(".chip").forEach(function (c) {
c.addEventListener("click", function () { setFilter(c.getAttribute("data-filter")); });
});
document.querySelectorAll(".stat").forEach(function (s) {
s.addEventListener("click", function () { setFilter(s.getAttribute("data-filter")); });
});
searchEl.addEventListener("input", function () {
state.query = searchEl.value.trim();
render();
});
// ---------- clock ----------
function tickClock() {
var d = new Date();
var h = String(d.getHours()).padStart(2, "0");
var m = String(d.getMinutes()).padStart(2, "0");
document.getElementById("clock").textContent = h + ":" + m;
}
tickClock();
setInterval(tickClock, 30000);
// ---------- live-ish updates ----------
// Advance in-use sessions; occasionally free up finished units or start new sessions.
setInterval(function () {
var changed = false;
equipment.forEach(function (e) {
if (e.status === STATUS.INUSE) {
e.elapsed += 1;
if (e.elapsed >= e.total) {
e.status = STATUS.FREE;
delete e.by; delete e.elapsed; delete e.total;
changed = true;
}
}
});
// Randomly start a session on one free unit.
if (Math.random() < 0.6) {
var freeUnits = equipment.filter(function (e) { return e.status === STATUS.FREE; });
if (freeUnits.length) {
var u = freeUnits[Math.floor(Math.random() * freeUnits.length)];
u.status = STATUS.INUSE;
u.by = randomName();
u.elapsed = 1;
u.total = 15 + Math.floor(Math.random() * 30);
changed = true;
}
}
updateSummary();
if (changed || modal.hidden) render();
}, 5000);
// ---------- init ----------
updateSummary();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Equipment Status — Ironworks Performance</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;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">IW</div>
<div class="brand-text">
<span class="eyebrow">Ironworks Performance</span>
<h1>Equipment Status</h1>
</div>
</div>
<div class="live">
<span class="dot" aria-hidden="true"></span>
<span>Live floor</span>
<span class="clock" id="clock">--:--</span>
</div>
</header>
<section class="summary" id="summary" aria-label="Status summary">
<button class="stat" data-filter="all" aria-pressed="true">
<span class="stat-num" id="count-all">0</span>
<span class="stat-label">Total units</span>
</button>
<button class="stat free" data-filter="free" aria-pressed="false">
<span class="stat-num" id="count-free">0</span>
<span class="stat-label">Free</span>
</button>
<button class="stat inuse" data-filter="inuse" aria-pressed="false">
<span class="stat-num" id="count-inuse">0</span>
<span class="stat-label">In use</span>
</button>
<button class="stat oos" data-filter="oos" aria-pressed="false">
<span class="stat-num" id="count-oos">0</span>
<span class="stat-label">Out of order</span>
</button>
</section>
<div class="toolbar">
<div class="search">
<svg viewBox="0 0 24 24" aria-hidden="true" class="search-ico"><path d="M21 21l-4.3-4.3M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<input id="search" type="search" placeholder="Search treadmill, rack, trainer…" aria-label="Search equipment" autocomplete="off" />
</div>
<div class="filters" role="group" aria-label="Filter by status">
<button class="chip is-active" data-filter="all">All</button>
<button class="chip" data-filter="free">Free</button>
<button class="chip" data-filter="inuse">In use</button>
<button class="chip" data-filter="oos">Out of order</button>
</div>
</div>
<main id="board" class="board" aria-live="polite"></main>
<p class="empty" id="empty" hidden>No equipment matches your filters.</p>
</div>
<!-- Report issue dialog -->
<div class="modal-backdrop" id="modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Report an issue</h2>
<p class="modal-sub" id="modal-sub">Mark this unit out of order so members and staff are warned.</p>
<label class="field">
<span>Reason</span>
<select id="reason">
<option value="Mechanical fault">Mechanical fault</option>
<option value="Belt slipping">Belt slipping</option>
<option value="Display not working">Display not working</option>
<option value="Loose / unsafe">Loose / unsafe</option>
<option value="Cleaning required">Cleaning required</option>
<option value="Other">Other</option>
</select>
</label>
<label class="field">
<span>Notes (optional)</span>
<textarea id="notes" rows="2" placeholder="e.g. Right pin sticks at 40kg"></textarea>
</label>
<div class="modal-actions">
<button class="btn ghost" id="cancel">Cancel</button>
<button class="btn danger" id="confirm">Mark out of order</button>
</div>
</div>
</div>
<div class="toast-wrap" id="toasts" aria-live="assertive"></div>
<script src="script.js"></script>
</body>
</html>Equipment Status
A high-energy status board for a gym floor at a glance. Equipment is grouped by category — Treadmills, Squat Racks, Benches, Rowers and Cable Machines — and every unit carries a colour-coded status pill: green for Free, orange for In use, red for Out of order. In-use cards name the member holding the unit, show elapsed minutes and render an ETA progress bar estimating how long is left in the session.
Four summary tiles across the top count the totals for each status and double as quick filters. Combine them with the pill filter chips and the instant search box to narrow the board to, say, only free racks or anything mentioning a particular trainer. A built-in Report issue dialog lets staff flip a unit out of order with a categorised reason and optional notes; out-of-order cards then expose a Mark fixed action to return them to service.
The board feels alive: a small interval advances active sessions, frees units when their time runs out, and occasionally spins up a new session on a free machine — all reflected in the counters and ETA bars in real time. It is built with vanilla HTML, CSS and JavaScript, is keyboard-usable with visible focus rings, respects reduced-motion preferences, and collapses to a single column on screens down to ~360px.