Gym — Workout Plan Builder
A drag-and-drop workout programming tool for coaches and lifters, built with vanilla JavaScript and native HTML5 drag events. A searchable exercise library grouped by muscle feeds into reorderable day blocks like Push, Pull and Legs. Each placed exercise exposes editable sets, reps and rest fields, while live per-day summaries estimate total sets and session duration. Add or remove days, reorder movements within a session, and watch the program stats update in real time.
MCP
Code
: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;
--sh-1: 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-2: 0 8px 24px rgba(0, 0, 0, 0.45);
--sh-3: 0 18px 48px rgba(0, 0, 0, 0.55);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
background:
radial-gradient(1200px 600px at 85% -10%, rgba(198, 255, 58, 0.06), transparent 60%),
radial-gradient(900px 500px at -5% 0%, rgba(255, 106, 43, 0.06), transparent 55%),
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;
min-height: 100vh;
}
.eyebrow {
display: block;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--neon);
}
.muted { color: var(--muted); }
:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
border-radius: 4px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 16px 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent), var(--surface);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(8px);
}
.brand { display: flex; align-items: center; gap: 14px; }
.logo {
width: 46px;
height: 46px;
border-radius: var(--r-md);
display: grid;
place-items: center;
font-weight: 900;
font-size: 18px;
letter-spacing: 0.04em;
color: #0d0f12;
background: linear-gradient(135deg, var(--neon), var(--neon-d));
box-shadow: 0 6px 18px rgba(198, 255, 58, 0.3);
}
.brand-text h1 {
margin: 2px 0 0;
font-size: 20px;
font-weight: 800;
letter-spacing: -0.01em;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.plan-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 6px 14px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
min-width: 64px;
}
.plan-stat-num {
font-size: 20px;
font-weight: 900;
line-height: 1.1;
color: var(--neon);
}
.plan-stat-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
/* ---------- Buttons ---------- */
.btn {
font-family: inherit;
font-weight: 700;
font-size: 14px;
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 11px 18px;
cursor: pointer;
color: var(--ink);
background: var(--surface-2);
transition: transform 0.08s ease, background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.btn:hover { background: var(--elevated); border-color: var(--line-2); }
.btn:active { transform: translateY(1px); }
.btn-primary {
background: linear-gradient(135deg, var(--neon), var(--neon-d));
color: #0d0f12;
border-color: transparent;
box-shadow: 0 6px 18px rgba(198, 255, 58, 0.25);
}
.btn-primary:hover { background: var(--neon); box-shadow: 0 8px 24px rgba(198, 255, 58, 0.35); }
.btn-ghost { background: transparent; }
.btn-add-day {
background: var(--orange-soft);
border-color: rgba(255, 106, 43, 0.4);
color: #ffd6c2;
}
.btn-add-day:hover { background: rgba(255, 106, 43, 0.22); color: #fff; }
/* ---------- Workspace ---------- */
.workspace {
display: grid;
grid-template-columns: 340px 1fr;
gap: 20px;
padding: 20px 24px 48px;
align-items: start;
}
/* ---------- Library ---------- */
.library {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
position: sticky;
top: 92px;
max-height: calc(100vh - 116px);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--sh-2);
}
.library-head { padding: 18px 18px 14px; border-bottom: 1px solid var(--line); }
.library-head h2 { margin: 0; font-size: 16px; font-weight: 800; }
.library-head .muted { font-size: 12.5px; margin: 2px 0 12px; }
.search-wrap input {
width: 100%;
font-family: inherit;
font-size: 14px;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 10px 14px;
}
.search-wrap input::placeholder { color: var(--muted); }
.search-wrap input:focus { border-color: var(--neon); outline: none; }
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.chip {
font-family: inherit;
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.03em;
color: var(--ink-2);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
padding: 6px 12px;
cursor: pointer;
transition: all 0.14s ease;
}
.chip:hover { border-color: var(--line-2); color: var(--ink); }
.chip[aria-selected="true"] {
background: var(--neon-50);
border-color: var(--neon);
color: var(--neon);
}
.library-list {
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.lib-group-label {
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
margin: 4px 2px 2px;
}
.lib-card {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 13px;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
cursor: grab;
transition: transform 0.1s ease, border-color 0.14s ease, background 0.14s ease;
}
.lib-card:hover { border-color: var(--line-2); background: var(--elevated); transform: translateX(2px); }
.lib-card:active { cursor: grabbing; }
.lib-card.dragging { opacity: 0.45; }
.lib-icon {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: var(--r-sm);
display: grid;
place-items: center;
font-size: 18px;
background: var(--neon-50);
}
.lib-info { min-width: 0; }
.lib-name { font-size: 14px; font-weight: 700; }
.lib-meta { font-size: 11.5px; color: var(--muted); }
.badge {
display: inline-block;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--line);
color: var(--ink-2);
}
.badge.compound { background: var(--orange-soft); border-color: rgba(255, 106, 43, 0.35); color: #ffb596; }
.badge.isolation { background: rgba(52, 211, 153, 0.12); border-color: rgba(52, 211, 153, 0.3); color: #8df0cf; }
.lib-empty { padding: 24px 8px; text-align: center; color: var(--muted); font-size: 13px; }
/* ---------- Board ---------- */
.board { min-width: 0; }
.board-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.program-title {
font-family: inherit;
font-size: 24px;
font-weight: 900;
letter-spacing: -0.02em;
color: var(--ink);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 2px 0;
margin-top: 2px;
width: 100%;
max-width: 560px;
}
.program-title:hover { border-bottom-color: var(--line); }
.program-title:focus { outline: none; border-bottom-color: var(--neon); }
.days {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
gap: 18px;
align-items: start;
}
/* ---------- Day block ---------- */
.day {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--sh-2);
transition: border-color 0.14s ease, box-shadow 0.14s ease;
}
.day.drop-active {
border-color: var(--neon);
box-shadow: 0 0 0 1px var(--neon), 0 18px 48px rgba(0, 0, 0, 0.55);
}
.day-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent);
border-bottom: 1px solid var(--line);
}
.day-index {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--neon);
}
.day-name {
font-family: inherit;
font-size: 16px;
font-weight: 800;
color: var(--ink);
background: transparent;
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 3px 6px;
flex: 1;
min-width: 0;
}
.day-name:hover { background: var(--surface-2); }
.day-name:focus { outline: none; border-color: var(--neon); background: var(--surface-2); }
.day-remove {
width: 30px;
height: 30px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: var(--r-sm);
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: all 0.14s ease;
}
.day-remove:hover { color: var(--danger); border-color: rgba(248, 113, 113, 0.5); background: rgba(248, 113, 113, 0.1); }
.day-body { padding: 12px; min-height: 90px; display: flex; flex-direction: column; gap: 10px; }
.day-empty {
border: 1.5px dashed var(--line-2);
border-radius: var(--r-md);
padding: 22px 12px;
text-align: center;
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
/* ---------- Exercise row ---------- */
.ex-row {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 10px 12px;
cursor: grab;
transition: border-color 0.14s ease, box-shadow 0.14s ease, opacity 0.1s ease;
}
.ex-row:hover { border-color: var(--line-2); }
.ex-row:active { cursor: grabbing; }
.ex-row.dragging { opacity: 0.4; }
.ex-row.drag-over-top { box-shadow: 0 -2px 0 var(--neon); }
.ex-row.drag-over-bottom { box-shadow: 0 2px 0 var(--neon); }
.ex-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 9px;
}
.ex-handle { color: var(--muted); font-size: 14px; cursor: grab; user-select: none; }
.ex-name { font-size: 14px; font-weight: 700; flex: 1; min-width: 0; }
.ex-muscle { font-size: 10.5px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
.ex-remove {
margin-left: auto;
width: 24px;
height: 24px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 14px;
line-height: 1;
transition: all 0.14s ease;
}
.ex-remove:hover { color: var(--danger); background: rgba(248, 113, 113, 0.12); }
.ex-fields { display: flex; gap: 8px; }
.field {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.field label {
font-size: 9.5px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.field input {
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 6px 8px;
width: 100%;
}
.field input:focus { border-color: var(--neon); outline: none; }
/* ---------- Day footer ---------- */
.day-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 11px 16px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.day-summary { font-size: 12px; color: var(--ink-2); }
.day-summary strong { color: var(--neon); font-weight: 800; }
.day-duration {
font-size: 12px;
font-weight: 700;
color: var(--ink-2);
display: flex;
align-items: center;
gap: 5px;
}
/* ---------- Toast ---------- */
.toast-stack {
position: fixed;
bottom: 22px;
right: 22px;
z-index: 60;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
background: var(--elevated);
border: 1px solid var(--line-2);
border-left: 3px solid var(--neon);
border-radius: var(--r-md);
padding: 12px 16px;
font-size: 13.5px;
font-weight: 600;
color: var(--ink);
box-shadow: var(--sh-3);
min-width: 200px;
transform: translateX(120%);
opacity: 0;
transition: transform 0.32s cubic-bezier(0.2, 0.9, 0.3, 1), opacity 0.3s ease;
}
.toast.show { transform: translateX(0); opacity: 1; }
/* ---------- Responsive ---------- */
@media (max-width: 920px) {
.workspace { grid-template-columns: 1fr; }
.library {
position: static;
max-height: 420px;
}
}
@media (max-width: 520px) {
.topbar { padding: 14px 16px; }
.brand-text h1 { font-size: 18px; }
.topbar-actions { gap: 8px; width: 100%; }
.plan-stat { min-width: 0; flex: 1; padding: 6px 8px; }
.btn { padding: 10px 14px; font-size: 13px; }
.workspace { padding: 16px 14px 40px; gap: 16px; }
.program-title { font-size: 20px; }
.days { grid-template-columns: 1fr; }
.ex-fields { flex-wrap: wrap; }
.field { flex: 1 1 28%; }
.toast-stack { left: 14px; right: 14px; bottom: 14px; }
.toast { min-width: 0; }
}(function () {
"use strict";
/* ---------- Exercise library data ---------- */
var LIBRARY = [
{ id: "ex-bench", name: "Barbell Bench Press", group: "Chest", type: "compound", icon: "🏋️", est: 9 },
{ id: "ex-incdb", name: "Incline Dumbbell Press", group: "Chest", type: "compound", icon: "💪", est: 7 },
{ id: "ex-fly", name: "Cable Fly", group: "Chest", type: "isolation", icon: "🔗", est: 5 },
{ id: "ex-pull", name: "Weighted Pull-Up", group: "Back", type: "compound", icon: "🧗", est: 8 },
{ id: "ex-row", name: "Barbell Row", group: "Back", type: "compound", icon: "🚣", est: 8 },
{ id: "ex-pulldown", name: "Lat Pulldown", group: "Back", type: "compound", icon: "⬇️", est: 6 },
{ id: "ex-ohp", name: "Overhead Press", group: "Shoulders", type: "compound", icon: "🆙", est: 8 },
{ id: "ex-lat", name: "Lateral Raise", group: "Shoulders", type: "isolation", icon: "↔️", est: 5 },
{ id: "ex-rear", name: "Rear Delt Fly", group: "Shoulders", type: "isolation", icon: "🦅", est: 5 },
{ id: "ex-squat", name: "Back Squat", group: "Legs", type: "compound", icon: "🦵", est: 10 },
{ id: "ex-rdl", name: "Romanian Deadlift", group: "Legs", type: "compound", icon: "⚙️", est: 9 },
{ id: "ex-legpress", name: "Leg Press", group: "Legs", type: "compound", icon: "🛗", est: 7 },
{ id: "ex-curl", name: "Leg Curl", group: "Legs", type: "isolation", icon: "🌀", est: 5 },
{ id: "ex-bicep", name: "EZ-Bar Curl", group: "Arms", type: "isolation", icon: "💥", est: 5 },
{ id: "ex-tricep", name: "Triceps Pushdown", group: "Arms", type: "isolation", icon: "🔻", est: 5 },
{ id: "ex-hammer", name: "Hammer Curl", group: "Arms", type: "isolation", icon: "🔨", est: 5 },
{ id: "ex-plank", name: "Weighted Plank", group: "Core", type: "isolation", icon: "🧱", est: 4 },
{ id: "ex-crunch", name: "Cable Crunch", group: "Core", type: "isolation", icon: "🌊", est: 4 }
];
var GROUPS = ["Chest", "Back", "Shoulders", "Legs", "Arms", "Core"];
/* ---------- State ---------- */
var uid = 0;
function nextId(prefix) { uid += 1; return prefix + "-" + Date.now().toString(36) + "-" + uid; }
// Each day: { id, name, exercises: [{ rowId, libId, name, group, type, est, sets, reps, rest }] }
var state = { days: [] };
function makeRow(libId, opts) {
var lib = LIBRARY.filter(function (x) { return x.id === libId; })[0];
if (!lib) return null;
opts = opts || {};
return {
rowId: nextId("row"),
libId: lib.id,
name: lib.name,
group: lib.group,
type: lib.type,
est: lib.est,
sets: opts.sets != null ? opts.sets : (lib.type === "compound" ? 4 : 3),
reps: opts.reps != null ? opts.reps : (lib.type === "compound" ? 8 : 12),
rest: opts.rest != null ? opts.rest : (lib.type === "compound" ? 120 : 60)
};
}
function seed() {
var day1 = { id: nextId("day"), name: "Day 1 — Push", exercises: [] };
["ex-bench", "ex-ohp", "ex-incdb", "ex-lat", "ex-tricep"].forEach(function (id) {
var r = makeRow(id); if (r) day1.exercises.push(r);
});
var day2 = { id: nextId("day"), name: "Day 2 — Pull", exercises: [] };
["ex-pull", "ex-row", "ex-pulldown", "ex-bicep", "ex-rear"].forEach(function (id) {
var r = makeRow(id); if (r) day2.exercises.push(r);
});
var day3 = { id: nextId("day"), name: "Day 3 — Legs", exercises: [] };
["ex-squat", "ex-rdl", "ex-legpress", "ex-curl", "ex-plank"].forEach(function (id) {
var r = makeRow(id); if (r) day3.exercises.push(r);
});
state.days = [day1, day2, day3];
}
/* ---------- DOM refs ---------- */
var libraryList = document.getElementById("libraryList");
var filterRow = document.getElementById("filterRow");
var searchInput = document.getElementById("searchInput");
var daysContainer = document.getElementById("daysContainer");
var addDayBtn = document.getElementById("addDayBtn");
var resetBtn = document.getElementById("resetBtn");
var saveBtn = document.getElementById("saveBtn");
var toastStack = document.getElementById("toastStack");
var statDays = document.getElementById("statDays");
var statExercises = document.getElementById("statExercises");
var statVolume = document.getElementById("statVolume");
var activeFilter = "All";
var searchTerm = "";
/* ---------- Toast ---------- */
function toast(msg) {
var el = document.createElement("div");
el.className = "toast";
el.textContent = msg;
toastStack.appendChild(el);
requestAnimationFrame(function () { el.classList.add("show"); });
setTimeout(function () {
el.classList.remove("show");
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 360);
}, 2400);
}
/* ---------- Library render ---------- */
function renderFilters() {
filterRow.innerHTML = "";
var all = ["All"].concat(GROUPS);
all.forEach(function (g) {
var chip = document.createElement("button");
chip.type = "button";
chip.className = "chip";
chip.textContent = g;
chip.setAttribute("role", "tab");
chip.setAttribute("aria-selected", g === activeFilter ? "true" : "false");
chip.addEventListener("click", function () {
activeFilter = g;
renderFilters();
renderLibrary();
});
filterRow.appendChild(chip);
});
}
function renderLibrary() {
libraryList.innerHTML = "";
var matches = LIBRARY.filter(function (ex) {
var byGroup = activeFilter === "All" || ex.group === activeFilter;
var bySearch = !searchTerm || ex.name.toLowerCase().indexOf(searchTerm) !== -1 || ex.group.toLowerCase().indexOf(searchTerm) !== -1;
return byGroup && bySearch;
});
if (!matches.length) {
var empty = document.createElement("div");
empty.className = "lib-empty";
empty.textContent = "No exercises match your search.";
libraryList.appendChild(empty);
return;
}
var groupsToShow = activeFilter === "All" ? GROUPS : [activeFilter];
groupsToShow.forEach(function (group) {
var inGroup = matches.filter(function (ex) { return ex.group === group; });
if (!inGroup.length) return;
var label = document.createElement("div");
label.className = "lib-group-label";
label.textContent = group;
libraryList.appendChild(label);
inGroup.forEach(function (ex) {
libraryList.appendChild(buildLibCard(ex));
});
});
}
function buildLibCard(ex) {
var card = document.createElement("div");
card.className = "lib-card";
card.setAttribute("draggable", "true");
card.dataset.libId = ex.id;
var icon = document.createElement("div");
icon.className = "lib-icon";
icon.textContent = ex.icon;
var info = document.createElement("div");
info.className = "lib-info";
var name = document.createElement("div");
name.className = "lib-name";
name.textContent = ex.name;
var meta = document.createElement("div");
meta.className = "lib-meta";
meta.innerHTML = '<span class="badge ' + ex.type + '">' + ex.type + "</span>";
info.appendChild(name);
info.appendChild(meta);
card.appendChild(icon);
card.appendChild(info);
card.addEventListener("dragstart", function (e) {
card.classList.add("dragging");
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/x-lib-id", ex.id);
e.dataTransfer.setData("text/plain", ex.id);
});
card.addEventListener("dragend", function () { card.classList.remove("dragging"); });
return card;
}
/* ---------- Days render ---------- */
function estDayMinutes(day) {
// base exercise est + ~ per-set time from rest
var mins = 0;
day.exercises.forEach(function (r) {
var sets = clampInt(r.sets, 1, 12);
var rest = clampInt(r.rest, 0, 600);
// working time ~ 45s/set + rest between sets
mins += (sets * 45 + (sets - 1) * rest) / 60;
});
return Math.round(mins);
}
function renderDays() {
daysContainer.innerHTML = "";
state.days.forEach(function (day, i) {
daysContainer.appendChild(buildDay(day, i));
});
updateStats();
}
function buildDay(day, index) {
var el = document.createElement("article");
el.className = "day";
el.dataset.dayId = day.id;
/* head */
var head = document.createElement("div");
head.className = "day-head";
var idx = document.createElement("span");
idx.className = "day-index";
idx.textContent = "Day " + (index + 1);
var nameInput = document.createElement("input");
nameInput.className = "day-name";
nameInput.value = day.name;
nameInput.setAttribute("aria-label", "Day name");
nameInput.addEventListener("input", function () { day.name = nameInput.value; });
var del = document.createElement("button");
del.className = "day-remove";
del.type = "button";
del.setAttribute("aria-label", "Remove " + day.name);
del.textContent = "×";
del.addEventListener("click", function () {
state.days = state.days.filter(function (d) { return d.id !== day.id; });
renderDays();
toast("Removed " + day.name);
});
head.appendChild(idx);
head.appendChild(nameInput);
head.appendChild(del);
/* body */
var body = document.createElement("div");
body.className = "day-body";
body.dataset.dayId = day.id;
if (!day.exercises.length) {
var empty = document.createElement("div");
empty.className = "day-empty";
empty.textContent = "Drag exercises here";
body.appendChild(empty);
} else {
day.exercises.forEach(function (row) {
body.appendChild(buildRow(day, row));
});
}
/* drop handling for adding from library */
body.addEventListener("dragover", function (e) {
e.preventDefault();
el.classList.add("drop-active");
e.dataTransfer.dropEffect = isRowDrag(e) ? "move" : "copy";
});
body.addEventListener("dragleave", function (e) {
if (!body.contains(e.relatedTarget)) el.classList.remove("drop-active");
});
body.addEventListener("drop", function (e) {
e.preventDefault();
el.classList.remove("drop-active");
var libId = e.dataTransfer.getData("application/x-lib-id");
if (libId && !isRowDrag(e)) {
var row = makeRow(libId);
if (row) {
day.exercises.push(row);
renderDays();
toast(row.name + " added to " + day.name);
}
}
});
/* footer */
var footer = document.createElement("div");
footer.className = "day-footer";
var summary = document.createElement("div");
summary.className = "day-summary";
summary.innerHTML = "<strong>" + day.exercises.length + "</strong> exercises · <strong>" +
totalSets(day) + "</strong> sets";
var dur = document.createElement("div");
dur.className = "day-duration";
dur.innerHTML = "⏱ ~" + estDayMinutes(day) + " min";
footer.appendChild(summary);
footer.appendChild(dur);
el.appendChild(head);
el.appendChild(body);
el.appendChild(footer);
return el;
}
function totalSets(day) {
return day.exercises.reduce(function (sum, r) { return sum + clampInt(r.sets, 0, 99); }, 0);
}
function buildRow(day, row) {
var el = document.createElement("div");
el.className = "ex-row";
el.setAttribute("draggable", "true");
el.dataset.rowId = row.rowId;
var top = document.createElement("div");
top.className = "ex-top";
var handle = document.createElement("span");
handle.className = "ex-handle";
handle.textContent = "⋮⋮";
handle.setAttribute("aria-hidden", "true");
var name = document.createElement("span");
name.className = "ex-name";
name.textContent = row.name;
var muscle = document.createElement("span");
muscle.className = "ex-muscle";
muscle.textContent = row.group;
var rm = document.createElement("button");
rm.className = "ex-remove";
rm.type = "button";
rm.setAttribute("aria-label", "Remove " + row.name);
rm.textContent = "×";
rm.addEventListener("click", function () {
day.exercises = day.exercises.filter(function (r) { return r.rowId !== row.rowId; });
renderDays();
toast("Removed " + row.name);
});
top.appendChild(handle);
top.appendChild(name);
top.appendChild(muscle);
top.appendChild(rm);
var fields = document.createElement("div");
fields.className = "ex-fields";
fields.appendChild(buildField("Sets", row.sets, 1, 12, function (v) { row.sets = v; }, day));
fields.appendChild(buildField("Reps", row.reps, 1, 50, function (v) { row.reps = v; }, day));
fields.appendChild(buildField("Rest s", row.rest, 0, 600, function (v) { row.rest = v; }, day));
el.appendChild(top);
el.appendChild(fields);
/* reorder drag */
el.addEventListener("dragstart", function (e) {
el.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/x-row-id", row.rowId);
e.dataTransfer.setData("application/x-row-day", day.id);
e.dataTransfer.setData("text/plain", row.rowId);
});
el.addEventListener("dragend", function () {
el.classList.remove("dragging");
el.classList.remove("drag-over-top");
el.classList.remove("drag-over-bottom");
});
el.addEventListener("dragover", function (e) {
if (!isRowDrag(e)) return;
e.preventDefault();
e.stopPropagation();
var rect = el.getBoundingClientRect();
var before = (e.clientY - rect.top) < rect.height / 2;
el.classList.toggle("drag-over-top", before);
el.classList.toggle("drag-over-bottom", !before);
});
el.addEventListener("dragleave", function () {
el.classList.remove("drag-over-top");
el.classList.remove("drag-over-bottom");
});
el.addEventListener("drop", function (e) {
if (!isRowDrag(e)) return;
e.preventDefault();
e.stopPropagation();
var rect = el.getBoundingClientRect();
var before = (e.clientY - rect.top) < rect.height / 2;
el.classList.remove("drag-over-top");
el.classList.remove("drag-over-bottom");
handleRowDrop(e, day, row.rowId, before);
});
return el;
}
function buildField(label, value, min, max, onChange, day) {
var wrap = document.createElement("div");
wrap.className = "field";
var lab = document.createElement("label");
lab.textContent = label;
var inp = document.createElement("input");
inp.type = "number";
inp.min = String(min);
inp.max = String(max);
inp.value = String(value);
inp.setAttribute("aria-label", label);
inp.addEventListener("input", function () {
var v = clampInt(parseInt(inp.value, 10), min, max);
onChange(v);
// live-update footer without full re-render
refreshDayFooter(day);
});
inp.addEventListener("blur", function () {
var v = clampInt(parseInt(inp.value, 10), min, max);
inp.value = String(v);
});
wrap.appendChild(lab);
wrap.appendChild(inp);
return wrap;
}
function refreshDayFooter(day) {
var dayEl = daysContainer.querySelector('[data-day-id="' + cssEscape(day.id) + '"]');
if (!dayEl) return;
var summary = dayEl.querySelector(".day-summary");
var dur = dayEl.querySelector(".day-duration");
if (summary) {
summary.innerHTML = "<strong>" + day.exercises.length + "</strong> exercises · <strong>" +
totalSets(day) + "</strong> sets";
}
if (dur) dur.innerHTML = "⏱ ~" + estDayMinutes(day) + " min";
updateStats();
}
/* ---------- Row drop / reorder logic ---------- */
function isRowDrag(e) {
var types = e.dataTransfer.types || [];
return Array.prototype.indexOf.call(types, "application/x-row-id") !== -1;
}
function handleRowDrop(e, targetDay, targetRowId, before) {
var rowId = e.dataTransfer.getData("application/x-row-id");
var fromDayId = e.dataTransfer.getData("application/x-row-day");
if (!rowId || rowId === targetRowId) return;
var fromDay = state.days.filter(function (d) { return d.id === fromDayId; })[0];
if (!fromDay) return;
var movingIdx = indexOfRow(fromDay, rowId);
if (movingIdx === -1) return;
var moving = fromDay.exercises.splice(movingIdx, 1)[0];
var targetIdx = indexOfRow(targetDay, targetRowId);
if (targetIdx === -1) {
targetDay.exercises.push(moving);
} else {
targetDay.exercises.splice(before ? targetIdx : targetIdx + 1, 0, moving);
}
renderDays();
}
function indexOfRow(day, rowId) {
for (var i = 0; i < day.exercises.length; i++) {
if (day.exercises[i].rowId === rowId) return i;
}
return -1;
}
/* ---------- Stats ---------- */
function updateStats() {
var exCount = 0, setCount = 0;
state.days.forEach(function (d) {
exCount += d.exercises.length;
setCount += totalSets(d);
});
statDays.textContent = String(state.days.length);
statExercises.textContent = String(exCount);
statVolume.textContent = String(setCount);
}
/* ---------- Helpers ---------- */
function clampInt(n, min, max) {
if (isNaN(n)) n = min;
n = Math.round(n);
if (n < min) n = min;
if (n > max) n = max;
return n;
}
function cssEscape(s) {
if (window.CSS && CSS.escape) return CSS.escape(s);
return String(s).replace(/["\\]/g, "\\$&");
}
/* ---------- Top-level actions ---------- */
addDayBtn.addEventListener("click", function () {
var n = state.days.length + 1;
state.days.push({ id: nextId("day"), name: "Day " + n + " — New", exercises: [] });
renderDays();
var last = daysContainer.lastElementChild;
if (last) last.scrollIntoView({ behavior: "smooth", block: "nearest" });
toast("Day " + n + " added");
});
resetBtn.addEventListener("click", function () {
seed();
renderDays();
toast("Plan reset to default split");
});
saveBtn.addEventListener("click", function () {
var title = document.getElementById("programTitle").value || "Untitled Program";
var exCount = state.days.reduce(function (s, d) { return s + d.exercises.length; }, 0);
toast("Saved “" + title + "” · " + state.days.length + " days, " + exCount + " exercises");
});
searchInput.addEventListener("input", function () {
searchTerm = searchInput.value.trim().toLowerCase();
renderLibrary();
});
/* ---------- Init ---------- */
seed();
renderFilters();
renderLibrary();
renderDays();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gym — Workout Plan Builder</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>
<header class="topbar">
<div class="brand">
<div class="logo" aria-hidden="true">IF</div>
<div class="brand-text">
<span class="eyebrow">Ironforge Athletics</span>
<h1>Workout Plan Builder</h1>
</div>
</div>
<div class="topbar-actions">
<div class="plan-stat">
<span class="plan-stat-num" id="statDays">0</span>
<span class="plan-stat-label">Days</span>
</div>
<div class="plan-stat">
<span class="plan-stat-num" id="statExercises">0</span>
<span class="plan-stat-label">Exercises</span>
</div>
<div class="plan-stat">
<span class="plan-stat-num" id="statVolume">0</span>
<span class="plan-stat-label">Total Sets</span>
</div>
<button class="btn btn-ghost" id="resetBtn" type="button">Reset</button>
<button class="btn btn-primary" id="saveBtn" type="button">Save Plan</button>
</div>
</header>
<main class="workspace">
<!-- Exercise library -->
<aside class="library" aria-label="Exercise library">
<div class="library-head">
<h2>Exercise Library</h2>
<p class="muted">Drag exercises into a day block</p>
<div class="search-wrap">
<input type="search" id="searchInput" placeholder="Search exercises…" aria-label="Search exercises" autocomplete="off" />
</div>
<div class="filter-row" id="filterRow" role="tablist" aria-label="Filter by muscle group"></div>
</div>
<div class="library-list" id="libraryList" aria-live="polite"></div>
</aside>
<!-- Program board -->
<section class="board" aria-label="Workout program">
<div class="board-head">
<div>
<span class="eyebrow">Current Program</span>
<input class="program-title" id="programTitle" value="6-Day Power Hypertrophy Split" aria-label="Program title" />
</div>
<button class="btn btn-add-day" id="addDayBtn" type="button">+ Add Day</button>
</div>
<div class="days" id="daysContainer"></div>
</section>
</main>
<div class="toast-stack" id="toastStack" aria-live="assertive" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Workout Plan Builder
A high-energy, dark-themed programming board for the fictional Ironforge Athletics gym. The left rail is a searchable exercise library grouped by muscle (Chest, Back, Shoulders, Legs, Arms, Core), with compound and isolation badges on every card. Filter chips and a live search box narrow the list instantly, and each card is a native drag source.
The center board holds day blocks — seeded with a Push / Pull / Legs split — that act as drop zones. Drag any library card into a day to append it as an editable row with sensible default sets, reps and rest. Within a day you can grab a row and reorder it, or drag it across to a different day; a neon insertion line shows exactly where it will land. Per-row number inputs are clamped to safe ranges, day names and the program title are editable inline, and you can add or delete day blocks at will.
Every change recomputes the summaries: each day footer shows its exercise count, total working sets and an estimated session length derived from sets and rest, while the topbar tallies days, exercises and total set volume across the whole program. Saving fires a toast confirmation. It is all plain HTML, CSS and vanilla JavaScript using the HTML5 Drag and Drop API — no frameworks, no build step.