Gym — Progress Stats
An athletic member progress panel built on the dark gym theme with hand-drawn SVG charts and zero libraries. A Strength / Cardio / Body tab switcher swaps a self-rendered bodyweight line chart, a weekly volume bar chart and a personal-records list for squat, bench, deadlift and 5k bests, each carrying up or down trend deltas and dates. A 4w / 12w / 1y range toggle re-renders the data, headline stat cards update, and hover or focus reveals neon point tooltips.
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;
--sh-1: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 6px 18px rgba(0, 0, 0, 0.4);
--sh-2: 0 12px 40px rgba(0, 0, 0, 0.55);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1100px 520px at 88% -8%, rgba(198, 255, 58, 0.07), transparent 60%),
radial-gradient(800px 460px at -6% 4%, rgba(255, 106, 43, 0.06), transparent 58%),
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;
}
.wrap {
max-width: 1080px;
margin: 0 auto;
padding: 40px 24px 64px;
}
/* ── Hero ── */
.hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
margin: 0;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--neon);
}
.eyebrow.sm {
font-size: 10px;
color: var(--muted);
}
.streak {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 13px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
font-size: 13px;
color: var(--ink-2);
}
.streak strong {
color: var(--ink);
}
.flame {
font-size: 14px;
}
.hero h1 {
margin: 12px 0 6px;
font-size: clamp(30px, 6vw, 46px);
font-weight: 900;
letter-spacing: -0.025em;
line-height: 1.02;
}
.sub {
margin: 0;
color: var(--muted);
font-size: 14px;
font-weight: 500;
}
.tier {
color: var(--neon);
font-weight: 700;
}
/* ── Tabs ── */
.tabs {
display: inline-flex;
gap: 4px;
margin: 26px 0 22px;
padding: 5px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface);
}
.tab {
appearance: none;
border: 0;
cursor: pointer;
padding: 9px 20px;
border-radius: 999px;
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.01em;
transition: color 0.18s, background 0.18s, transform 0.1s;
}
.tab:hover {
color: var(--ink);
}
.tab.is-active {
background: var(--neon);
color: #0d0f12;
}
.tab:active {
transform: translateY(1px);
}
/* ── Stat cards ── */
.stat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
margin-bottom: 18px;
}
.stat-card {
padding: 18px 18px 16px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: linear-gradient(180deg, var(--surface-2), var(--surface));
box-shadow: var(--sh-1);
}
.stat-label {
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.stat-value {
margin: 0 0 8px;
font-size: 27px;
font-weight: 800;
letter-spacing: -0.02em;
}
.stat-value .unit {
font-size: 14px;
font-weight: 600;
color: var(--muted);
}
.delta {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 12.5px;
font-weight: 700;
}
.delta .arrow {
font-size: 10px;
}
.delta.up {
color: var(--ok);
}
.delta.down {
color: var(--orange);
}
.delta.flat {
color: var(--muted);
}
/* ── Panels ── */
.panels {
display: grid;
grid-template-columns: 1.55fr 1fr;
gap: 16px;
}
.panel {
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: var(--surface);
box-shadow: var(--sh-2);
padding: 20px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 16px;
}
.panel-head h2 {
margin: 4px 0 0;
font-size: 18px;
font-weight: 800;
letter-spacing: -0.01em;
}
/* ── Range toggle ── */
.range {
display: inline-flex;
gap: 3px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
}
.range-btn {
appearance: none;
border: 0;
cursor: pointer;
padding: 6px 12px;
border-radius: 999px;
background: transparent;
color: var(--ink-2);
font: inherit;
font-size: 12px;
font-weight: 700;
transition: background 0.16s, color 0.16s;
}
.range-btn:hover {
color: var(--ink);
}
.range-btn.is-active {
background: var(--elevated);
color: var(--neon);
}
/* ── Chart ── */
.chart-stage {
position: relative;
}
.chart {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.chart .grid-line {
stroke: var(--line);
stroke-width: 1;
}
.chart .axis-label {
fill: var(--muted);
font-size: 11px;
font-weight: 600;
font-family: inherit;
}
.chart .area {
fill: url(#areaGrad);
opacity: 0;
animation: rise 0.5s ease forwards;
}
.chart .line {
fill: none;
stroke: var(--neon);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
}
.chart .target-line {
fill: none;
stroke: var(--orange);
stroke-width: 1.5;
stroke-dasharray: 5 5;
opacity: 0.8;
}
.chart .pt {
fill: var(--bg);
stroke: var(--neon);
stroke-width: 2.5;
cursor: pointer;
transition: r 0.12s ease;
}
.chart .pt:hover,
.chart .pt.is-hot {
fill: var(--neon);
r: 6;
}
.chart .hit {
fill: transparent;
cursor: pointer;
}
.chart .bar {
fill: var(--neon-50);
stroke: var(--neon);
stroke-width: 1.5;
cursor: pointer;
transition: fill 0.14s ease;
transform-box: fill-box;
transform-origin: bottom;
animation: grow 0.45s cubic-bezier(0.2, 0.7, 0.2, 1) forwards;
}
.chart .bar:hover,
.chart .bar.is-hot {
fill: var(--neon);
}
@keyframes rise {
to {
opacity: 1;
}
}
@keyframes grow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.tooltip {
position: absolute;
pointer-events: none;
z-index: 4;
min-width: 96px;
padding: 8px 11px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--elevated);
box-shadow: var(--sh-2);
font-size: 12px;
color: var(--ink);
opacity: 0;
transform: translate(-50%, -8px);
transition: opacity 0.12s ease;
}
.tooltip.show {
opacity: 1;
}
.tooltip .tt-label {
display: block;
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 2px;
}
.tooltip .tt-val {
font-weight: 800;
font-size: 14px;
}
.tooltip .tt-val b {
color: var(--neon);
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 18px;
margin-top: 14px;
font-size: 12px;
color: var(--ink-2);
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 7px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot.neon {
background: var(--neon);
}
.dot.orange {
background: var(--orange);
}
/* ── PR list ── */
.pr-count {
font-size: 12px;
font-weight: 700;
color: var(--muted);
padding: 5px 11px;
border: 1px solid var(--line);
border-radius: 999px;
white-space: nowrap;
}
.pr-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.pr-item {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
padding: 13px 14px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface-2);
transition: border-color 0.16s, transform 0.12s, background 0.16s;
}
.pr-item:hover {
border-color: var(--line-2);
background: var(--elevated);
transform: translateY(-1px);
}
.pr-main {
min-width: 0;
}
.pr-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 700;
}
.pr-name .ico {
font-size: 15px;
}
.pr-date {
margin-top: 3px;
font-size: 11.5px;
color: var(--muted);
font-weight: 500;
}
.pr-right {
text-align: right;
}
.pr-val {
font-size: 18px;
font-weight: 800;
letter-spacing: -0.01em;
}
.pr-val .u {
font-size: 11px;
font-weight: 600;
color: var(--muted);
}
.pr-trend {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 3px;
font-size: 11.5px;
font-weight: 700;
}
.pr-trend.up {
color: var(--ok);
}
.pr-trend.down {
color: var(--danger);
}
.pr-trend.flat {
color: var(--muted);
}
/* ── Toast ── */
.toast-host {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
display: grid;
gap: 8px;
z-index: 50;
pointer-events: none;
}
.toast {
padding: 11px 16px;
border: 1px solid var(--line-2);
border-radius: 999px;
background: var(--elevated);
color: var(--ink);
font-size: 13px;
font-weight: 600;
box-shadow: var(--sh-2);
opacity: 0;
transform: translateY(10px);
transition: opacity 0.2s, transform 0.2s;
}
.toast.in {
opacity: 1;
transform: translateY(0);
}
.toast .tk {
color: var(--neon);
font-weight: 800;
}
/* ── Focus ── */
:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
border-radius: 4px;
}
/* ── Responsive ── */
@media (max-width: 860px) {
.panels {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.wrap {
padding: 28px 16px 48px;
}
.hero-top {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.stat-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.stat-value {
font-size: 24px;
}
.tabs {
display: flex;
width: 100%;
}
.tab {
flex: 1;
padding: 9px 10px;
text-align: center;
}
.panel {
padding: 16px;
}
.panel-head {
flex-direction: column;
}
.legend {
gap: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
.chart .area,
.chart .bar,
.toast,
.pr-item,
.tab {
animation: none !important;
transition: none !important;
}
}/* Gym — Progress Stats. Vanilla JS, no libraries. Self-drawn SVG charts. */
(function () {
"use strict";
var NS = "http://www.w3.org/2000/svg";
/* ── Toast helper ── */
var toastHost = document.querySelector(".toast-host");
function toast(msg) {
var el = document.createElement("div");
el.className = "toast";
el.innerHTML = msg;
toastHost.appendChild(el);
requestAnimationFrame(function () {
el.classList.add("in");
});
setTimeout(function () {
el.classList.remove("in");
setTimeout(function () {
el.remove();
}, 250);
}, 2200);
}
/* ── Data: per-metric series + PRs ──
Each metric carries series for 4w / 12w / 52w plus a chart "kind". */
function seed(rangeKey, base, drift, jitter, count) {
var pts = [];
var rng = mulberry(rangeKey * 9301 + count * 49297);
var v = base;
for (var i = 0; i < count; i++) {
v += drift + (rng() - 0.5) * jitter;
pts.push(Math.round(v * 10) / 10);
}
return pts;
}
function mulberry(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function labelsFor(range) {
if (range === 4) return ["W1", "W2", "W3", "W4"];
if (range === 12) return ["W1", "W3", "W5", "W7", "W9", "W11", "W12"];
return ["Jan", "Mar", "May", "Jul", "Sep", "Nov", "Now"];
}
function countFor(range) {
return range === 4 ? 4 : range === 12 ? 7 : 7;
}
var METRICS = {
strength: {
eyebrow: "Total volume",
title: "Weekly training volume",
legend: "Volume (kg)",
kind: "bar",
unit: "kg",
series: {
4: seed(11, 13800, 220, 1400, 4),
12: seed(12, 12200, 520, 2200, 7),
52: seed(15, 9800, 1100, 3200, 7),
},
target: { 4: 14500, 12: 14500, 52: 14500 },
},
cardio: {
eyebrow: "5k pace",
title: "Average running pace",
legend: "Pace (sec/km)",
kind: "line",
unit: "s/km",
series: {
4: seed(21, 312, -2.2, 6, 4),
12: seed(22, 326, -2.6, 8, 7),
52: seed(25, 358, -4.4, 12, 7),
},
target: { 4: 300, 12: 300, 52: 300 },
},
body: {
eyebrow: "Bodyweight trend",
title: "Where you're heading",
legend: "Bodyweight (kg)",
kind: "line",
unit: "kg",
series: {
4: seed(31, 82.4, -0.32, 0.5, 4),
12: seed(32, 83.6, -0.36, 0.7, 7),
52: seed(35, 88.5, -0.7, 1.1, 7),
},
target: { 4: 79.5, 12: 79.5, 52: 79.5 },
},
};
var PRS = {
strength: [
{ ico: "🏋️", name: "Back Squat", val: 182.5, u: "kg", date: "May 28", trend: "up", d: "+5.0 kg" },
{ ico: "💪", name: "Bench Press", val: 122.5, u: "kg", date: "Jun 02", trend: "up", d: "+2.5 kg" },
{ ico: "🪨", name: "Deadlift", val: 215.0, u: "kg", date: "May 14", trend: "flat", d: "matched" },
{ ico: "🤸", name: "Overhead Press", val: 72.5, u: "kg", date: "Apr 30", trend: "up", d: "+2.5 kg" },
],
cardio: [
{ ico: "🏃", name: "5k Run", val: "22:48", u: "", date: "Jun 01", trend: "up", d: "-41 s" },
{ ico: "🚴", name: "10k Bike", val: "16:12", u: "", date: "May 22", trend: "up", d: "-22 s" },
{ ico: "🛶", name: "2k Row", val: "7:38", u: "", date: "May 09", trend: "down", d: "+6 s" },
{ ico: "🧗", name: "Plank Hold", val: "4:05", u: "", date: "Apr 27", trend: "up", d: "+18 s" },
],
body: [
{ ico: "⚖️", name: "Bodyweight", val: 81.2, u: "kg", date: "Jun 08", trend: "down", d: "-2.1 kg" },
{ ico: "📏", name: "Body fat", val: "14.6", u: "%", date: "Jun 06", trend: "down", d: "-1.4 %" },
{ ico: "💪", name: "Arm girth", val: 39.4, u: "cm", date: "May 31", trend: "up", d: "+0.6 cm" },
{ ico: "🫀", name: "Resting HR", val: 54, u: "bpm", date: "Jun 04", trend: "down", d: "-3 bpm" },
],
};
/* ── State ── */
var state = { metric: "strength", range: 12 };
/* ── Chart geometry ── */
var W = 720;
var H = 320;
var PAD = { l: 44, r: 16, t: 18, b: 30 };
var IW = W - PAD.l - PAD.r;
var IH = H - PAD.t - PAD.b;
var svg = document.querySelector(".chart");
var tooltip = document.querySelector(".tooltip");
function el(tag, attrs) {
var e = document.createElementNS(NS, tag);
for (var k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function niceBounds(vals, target) {
var all = vals.concat([target]);
var min = Math.min.apply(null, all);
var max = Math.max.apply(null, all);
var span = max - min || 1;
return { min: min - span * 0.15, max: max + span * 0.15 };
}
function fmt(v, metric) {
if (metric === "cardio") {
var m = Math.floor(v / 60);
var s = Math.round(v % 60);
return m + ":" + (s < 10 ? "0" + s : s);
}
return Number.isInteger(v) ? String(v) : v.toFixed(1);
}
function drawChart() {
var m = METRICS[state.metric];
var data = m.series[state.range];
var labels = labelsFor(state.range);
var target = m.target[state.range];
var b = niceBounds(data, target);
while (svg.firstChild) svg.removeChild(svg.firstChild);
// defs gradient
var defs = el("defs", {});
var grad = el("linearGradient", { id: "areaGrad", x1: "0", y1: "0", x2: "0", y2: "1" });
grad.appendChild(el("stop", { offset: "0%", "stop-color": "#c6ff3a", "stop-opacity": "0.28" }));
grad.appendChild(el("stop", { offset: "100%", "stop-color": "#c6ff3a", "stop-opacity": "0" }));
defs.appendChild(grad);
svg.appendChild(defs);
var x = function (i) {
return PAD.l + (data.length === 1 ? IW / 2 : (i / (data.length - 1)) * IW);
};
var y = function (v) {
return PAD.t + IH - ((v - b.min) / (b.max - b.min)) * IH;
};
// grid + y labels (4 rows)
for (var g = 0; g <= 4; g++) {
var gv = b.min + ((b.max - b.min) * g) / 4;
var gy = y(gv);
svg.appendChild(el("line", { class: "grid-line", x1: PAD.l, y1: gy, x2: W - PAD.r, y2: gy }));
var yl = el("text", { class: "axis-label", x: PAD.l - 8, y: gy + 4, "text-anchor": "end" });
yl.textContent = fmt(gv, state.metric);
svg.appendChild(yl);
}
// x labels
var step = data.length / labels.length;
for (var li = 0; li < labels.length; li++) {
var xi = Math.min(data.length - 1, Math.round(li * step));
var xt = el("text", { class: "axis-label", x: x(xi), y: H - 8, "text-anchor": "middle" });
xt.textContent = labels[li];
svg.appendChild(xt);
}
// target line
var ty = y(target);
svg.appendChild(el("line", { class: "target-line", x1: PAD.l, y1: ty, x2: W - PAD.r, y2: ty }));
if (m.kind === "line") {
drawLine(data, x, y, m);
} else {
drawBars(data, x, y, m, b);
}
}
function drawLine(data, x, y, m) {
var d = "";
var area = "M" + x(0) + " " + (PAD.t + IH);
data.forEach(function (v, i) {
d += (i === 0 ? "M" : "L") + x(i) + " " + y(v) + " ";
area += "L" + x(i) + " " + y(v) + " ";
});
area += "L" + x(data.length - 1) + " " + (PAD.t + IH) + " Z";
svg.appendChild(el("path", { class: "area", d: area }));
svg.appendChild(el("path", { class: "line", d: d.trim() }));
data.forEach(function (v, i) {
var cx = x(i);
var cy = y(v);
var pt = el("circle", { class: "pt", cx: cx, cy: cy, r: 4, "data-i": i });
var hit = el("circle", { class: "hit", cx: cx, cy: cy, r: 18, "data-i": i, tabindex: "0" });
svg.appendChild(pt);
svg.appendChild(hit);
bindPoint(hit, pt, v, m, cx, cy);
});
}
function drawBars(data, x, y, m, b) {
var bw = (IW / data.length) * 0.6;
data.forEach(function (v, i) {
var cx = x(i);
var top = y(v);
var bottom = PAD.t + IH;
var bar = el("rect", {
class: "bar",
x: cx - bw / 2,
y: top,
width: bw,
height: Math.max(2, bottom - top),
rx: 4,
"data-i": i,
tabindex: "0",
});
bar.style.animationDelay = i * 45 + "ms";
svg.appendChild(bar);
bindPoint(bar, bar, v, m, cx, top);
});
}
function bindPoint(hit, visual, v, m, cx, cy) {
function show() {
visual.classList.add("is-hot");
var rect = svg.getBoundingClientRect();
var stage = svg.parentElement.getBoundingClientRect();
var px = ((cx / W) * rect.width) + (rect.left - stage.left);
var py = ((cy / H) * rect.height) + (rect.top - stage.top);
tooltip.style.left = px + "px";
tooltip.style.top = py + "px";
tooltip.innerHTML =
'<span class="tt-label">' + m.legend + "</span>" +
'<span class="tt-val"><b>' + fmt(v, state.metric) + "</b> " + m.unit + "</span>";
tooltip.classList.add("show");
}
function hide() {
visual.classList.remove("is-hot");
tooltip.classList.remove("show");
}
hit.addEventListener("mouseenter", show);
hit.addEventListener("mouseleave", hide);
hit.addEventListener("focus", show);
hit.addEventListener("blur", hide);
}
/* ── PR list ── */
var prList = document.querySelector("[data-pr-list]");
var prCount = document.querySelector("[data-pr-count]");
var prTitle = document.querySelector("[data-pr-title]");
var arrows = { up: "▲", down: "▼", flat: "■" };
function renderPRs() {
var rows = PRS[state.metric];
prList.innerHTML = "";
rows.forEach(function (r) {
var li = document.createElement("li");
li.className = "pr-item";
li.tabIndex = 0;
li.innerHTML =
'<div class="pr-main">' +
'<div class="pr-name"><span class="ico">' + r.ico + "</span>" + r.name + "</div>" +
'<div class="pr-date">' + r.date + "</div>" +
"</div>" +
'<div class="pr-right">' +
'<div class="pr-val">' + r.val + (r.u ? ' <span class="u">' + r.u + "</span>" : "") + "</div>" +
'<div class="pr-trend ' + r.trend + '">' + arrows[r.trend] + " " + r.d + "</div>" +
"</div>";
li.addEventListener("click", function () {
toast('<span class="tk">' + r.name + "</span> · " + r.val + (r.u ? " " + r.u : "") + " on " + r.date);
});
li.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
li.click();
}
});
prList.appendChild(li);
});
prCount.textContent = rows.length + " " + (state.metric === "body" ? "metrics" : "lifts");
prTitle.textContent = state.metric === "body" ? "Body measurements" : state.metric === "cardio" ? "Cardio bests" : "Current PRs";
}
/* ── Summary stat cards (refresh per metric) ── */
function refreshSummary() {
var m = METRICS[state.metric];
var data = m.series[12];
var first = data[0];
var last = data[data.length - 1];
var diff = last - first;
var pct = ((diff / first) * 100).toFixed(1);
setVal("[data-stat=volume]", fmt(last, state.metric) + ' <span class="unit">' + m.unit + "</span>");
var betterUp = state.metric === "strength";
var cls = diff === 0 ? "flat" : (diff > 0) === betterUp ? "up" : "down";
setDelta("[data-stat=volumeDelta]", cls, Math.abs(pct) + "% vs start");
document.querySelector(".stat-card:nth-child(1) .stat-label").textContent =
m.eyebrow + " · 12w";
}
function setVal(sel, html) {
var e = document.querySelector(sel);
if (e) e.innerHTML = html;
}
function setDelta(sel, cls, text) {
var e = document.querySelector(sel);
if (!e) return;
e.className = "delta " + cls;
e.innerHTML = '<span class="arrow">' + (cls === "up" ? "▲" : cls === "down" ? "▼" : "■") + "</span> " + text;
}
/* ── Chart header text ── */
function refreshChartHead() {
var m = METRICS[state.metric];
document.querySelector("[data-chart-eyebrow]").textContent = m.eyebrow;
document.querySelector("[data-chart-title]").textContent = m.title;
document.querySelector("[data-legend]").textContent = m.legend;
}
/* ── Wire tabs ── */
document.querySelectorAll(".tab").forEach(function (tab) {
tab.addEventListener("click", function () {
if (tab.classList.contains("is-active")) return;
document.querySelectorAll(".tab").forEach(function (t) {
t.classList.remove("is-active");
t.setAttribute("aria-selected", "false");
});
tab.classList.add("is-active");
tab.setAttribute("aria-selected", "true");
state.metric = tab.dataset.metric;
renderAll();
toast("Showing <span class=\"tk\">" + tab.textContent.trim() + "</span> progress");
});
});
/* ── Wire range ── */
document.querySelectorAll(".range-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
if (btn.classList.contains("is-active")) return;
document.querySelectorAll(".range-btn").forEach(function (b) {
b.classList.remove("is-active");
});
btn.classList.add("is-active");
state.range = parseInt(btn.dataset.range, 10);
drawChart();
var label = btn.textContent.trim();
toast("Range set to <span class=\"tk\">" + label + "</span>");
});
});
function renderAll() {
refreshChartHead();
drawChart();
renderPRs();
refreshSummary();
}
renderAll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gym — Progress Stats</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>
<main class="wrap">
<header class="hero">
<div class="hero-top">
<p class="eyebrow">Member dashboard</p>
<div class="streak" aria-label="Current training streak">
<span class="flame" aria-hidden="true">🔥</span>
<span><strong>18</strong> day streak</span>
</div>
</div>
<h1>Progress Stats</h1>
<p class="sub">
Marcus Whitfield · <span class="tier">Elite Plan</span> · last logged today
</p>
</header>
<!-- Metric tabs -->
<div class="tabs" role="tablist" aria-label="Metric category">
<button class="tab is-active" role="tab" aria-selected="true" data-metric="strength">
Strength
</button>
<button class="tab" role="tab" aria-selected="false" data-metric="cardio">Cardio</button>
<button class="tab" role="tab" aria-selected="false" data-metric="body">Body</button>
</div>
<!-- Summary stat cards -->
<section class="stat-grid" aria-label="Headline stats">
<article class="stat-card">
<p class="stat-label">Total volume · 12w</p>
<p class="stat-value" data-stat="volume">184,250 <span class="unit">kg</span></p>
<p class="delta up" data-stat="volumeDelta"><span class="arrow">▲</span> 12.4% vs prev</p>
</article>
<article class="stat-card">
<p class="stat-label">Sessions · 12w</p>
<p class="stat-value" data-stat="sessions">46</p>
<p class="delta up" data-stat="sessionsDelta"><span class="arrow">▲</span> 6 sessions</p>
</article>
<article class="stat-card">
<p class="stat-label">Bodyweight</p>
<p class="stat-value" data-stat="weight">81.2 <span class="unit">kg</span></p>
<p class="delta down" data-stat="weightDelta"><span class="arrow">▼</span> 2.1 kg</p>
</article>
</section>
<div class="panels">
<!-- Chart panel -->
<section class="panel chart-panel" aria-label="Trend chart">
<div class="panel-head">
<div>
<p class="eyebrow sm" data-chart-eyebrow>Bodyweight trend</p>
<h2 data-chart-title>Where you're heading</h2>
</div>
<div class="range" role="group" aria-label="Time range">
<button class="range-btn" data-range="4">4w</button>
<button class="range-btn is-active" data-range="12">12w</button>
<button class="range-btn" data-range="52">1y</button>
</div>
</div>
<div class="chart-stage">
<svg
class="chart"
viewBox="0 0 720 320"
preserveAspectRatio="none"
role="img"
aria-label="Trend chart"
></svg>
<div class="tooltip" role="status" aria-live="polite"></div>
</div>
<div class="legend">
<span class="legend-item"><i class="dot neon"></i><span data-legend>Bodyweight (kg)</span></span>
<span class="legend-item"><i class="dot orange"></i>Weekly target</span>
</div>
</section>
<!-- PR panel -->
<section class="panel pr-panel" aria-label="Personal records">
<div class="panel-head">
<div>
<p class="eyebrow sm">Personal records</p>
<h2 data-pr-title>Current PRs</h2>
</div>
<span class="pr-count" data-pr-count>4 lifts</span>
</div>
<ul class="pr-list" data-pr-list></ul>
</section>
</div>
</main>
<div class="toast-host" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Progress Stats
A high-energy member dashboard for tracking training progress on the dark performance-gym theme. A segmented Strength / Cardio / Body tab switcher drives the whole panel: it swaps the chart type, its data, the headline summary cards and the personal-records list at once. Strength shows a weekly volume bar chart, while Cardio and Body render smooth line charts with a filled area and a dashed orange target line — all drawn by hand as raw SVG with no charting library.
The chart points and bars respond to hover and keyboard focus with a neon tooltip showing the exact value, and a 4w / 12w / 1y range toggle recomputes and re-renders the series in place. Below the chart, a PR list lists lifts like Back Squat, Bench Press and Deadlift (or cardio bests and body measurements) with little up/down/flat trend deltas and the date each was set; clicking a row fires a toast with the record detail.
Headline stat cards summarise total volume, sessions and bodyweight with colour-coded deltas, and the layout collapses to a single column down to ~360px. Names, lifts and dates are realistic but fictional. Everything is vanilla JS — no frameworks, no build step.