Gym — Revenue & Retention Dashboard
An admin analytics dashboard for a performance gym, pairing KPI cards for MRR, ARPU, churn and lifetime value with trend deltas and inline sparklines, a hand-drawn SVG revenue-over-time area chart with hover tooltips, a membership-mix donut, a cohort-retention heat grid showing months against joining cohorts by color intensity, and a top-classes-by-revenue bar list. A 30d / 90d / 12m range toggle re-renders the metrics and charts. Every chart is drawn in vanilla JS with no libraries.
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.03), 0 1px 2px rgba(0, 0, 0, 0.4);
--sh-2: 0 8px 24px rgba(0, 0, 0, 0.45), 0 2px 6px rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: radial-gradient(1200px 600px at 80% -10%, rgba(198, 255, 58, 0.05), transparent 60%),
radial-gradient(900px 500px at -10% 10%, rgba(255, 106, 43, 0.05), transparent 55%), var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
button {
font-family: inherit;
cursor: pointer;
}
:focus-visible {
outline: 2px solid var(--neon);
outline-offset: 2px;
border-radius: 6px;
}
.shell {
max-width: 1180px;
margin: 0 auto;
padding: 22px clamp(14px, 3vw, 28px) 48px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand__mark {
width: 44px;
height: 44px;
border-radius: 12px;
display: grid;
place-items: center;
font-weight: 900;
font-size: 17px;
letter-spacing: -0.02em;
color: #0d0f12;
background: linear-gradient(135deg, var(--neon), var(--neon-d));
box-shadow: 0 6px 18px rgba(198, 255, 58, 0.25);
}
.brand__txt {
display: flex;
flex-direction: column;
line-height: 1.15;
}
.brand__name {
font-weight: 800;
font-size: 18px;
letter-spacing: -0.01em;
}
.brand__sub {
font-size: 12px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
.topbar__right {
display: flex;
align-items: center;
gap: 10px;
}
.range {
display: inline-flex;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 12px;
padding: 4px;
gap: 2px;
}
.range__btn {
border: 0;
background: transparent;
color: var(--ink-2);
font-weight: 700;
font-size: 13px;
padding: 8px 14px;
border-radius: 9px;
transition: background 0.16s, color 0.16s, transform 0.1s;
}
.range__btn:hover {
color: var(--ink);
}
.range__btn:active {
transform: scale(0.96);
}
.range__btn.is-active {
background: var(--neon);
color: #0d0f12;
box-shadow: 0 2px 8px rgba(198, 255, 58, 0.3);
}
.export {
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
font-weight: 700;
font-size: 13px;
padding: 9px 16px;
border-radius: 11px;
transition: border-color 0.16s, background 0.16s, transform 0.1s;
}
.export:hover {
border-color: var(--neon);
background: var(--elevated);
}
.export:active {
transform: scale(0.97);
}
/* ---------- KPI cards ---------- */
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.kpi {
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 16px 12px;
box-shadow: var(--sh-1);
position: relative;
overflow: hidden;
transition: border-color 0.18s, transform 0.18s;
}
.kpi:hover {
border-color: var(--line-2);
transform: translateY(-2px);
}
.kpi__eyebrow {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.09em;
font-weight: 700;
color: var(--muted);
}
.kpi__row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
}
.kpi__value {
font-size: clamp(22px, 3vw, 28px);
font-weight: 900;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.kpi__delta {
font-size: 12px;
font-weight: 800;
padding: 3px 8px;
border-radius: 999px;
white-space: nowrap;
}
.kpi__delta.is-up {
color: var(--ok);
background: rgba(52, 211, 153, 0.14);
}
.kpi__delta.is-down {
color: var(--danger);
background: rgba(248, 113, 113, 0.14);
}
.kpi__spark {
margin-top: 10px;
height: 36px;
}
.kpi__spark svg {
width: 100%;
height: 36px;
display: block;
}
/* ---------- Grid + panels ---------- */
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 16px;
}
.panel {
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: var(--sh-2);
}
.panel--wide {
grid-column: 1 / -1;
}
.panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.panel__title {
margin: 0;
font-size: 16px;
font-weight: 800;
letter-spacing: -0.01em;
}
.panel__hint {
margin: 3px 0 0;
font-size: 12.5px;
color: var(--muted);
}
.legend {
display: flex;
gap: 14px;
flex-shrink: 0;
}
.legend__item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
}
.dot {
width: 9px;
height: 9px;
border-radius: 3px;
display: inline-block;
}
.dot--neon {
background: var(--neon);
}
.dot--orange {
background: var(--orange);
}
/* ---------- Revenue chart ---------- */
.chart {
position: relative;
}
.chart svg {
width: 100%;
height: auto;
display: block;
overflow: visible;
}
.chart .axis-line {
stroke: var(--line);
stroke-width: 1;
}
.chart .axis-label {
fill: var(--muted);
font-size: 11px;
font-weight: 600;
}
.chart .hover-line {
stroke: var(--line-2);
stroke-width: 1;
stroke-dasharray: 3 3;
}
.chart .hover-dot {
stroke: var(--bg);
stroke-width: 2;
}
.chart__tip {
position: absolute;
pointer-events: none;
background: var(--elevated);
border: 1px solid var(--line-2);
border-radius: 10px;
padding: 9px 11px;
font-size: 12px;
box-shadow: var(--sh-2);
transform: translate(-50%, -118%);
white-space: nowrap;
z-index: 5;
}
.chart__tip b {
display: block;
font-size: 11px;
color: var(--muted);
font-weight: 700;
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.chart__tip .tip-row {
display: flex;
align-items: center;
gap: 6px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.chart__tip .tip-row + .tip-row {
margin-top: 3px;
}
/* ---------- Donut ---------- */
.donut {
position: relative;
width: 180px;
max-width: 100%;
margin: 4px auto 6px;
}
.donut svg {
width: 100%;
height: auto;
display: block;
}
.donut .arc {
cursor: pointer;
transition: opacity 0.16s, transform 0.16s;
transform-origin: 90px 90px;
}
.donut .arc:hover {
opacity: 0.85;
}
.donut .arc.is-dim {
opacity: 0.3;
}
.donut__center {
position: absolute;
inset: 0;
display: grid;
place-content: center;
text-align: center;
pointer-events: none;
}
.donut__total {
font-size: 30px;
font-weight: 900;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.donut__label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
font-weight: 700;
}
.mix {
list-style: none;
margin: 6px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mix__row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 8px;
border-radius: 9px;
transition: background 0.14s;
}
.mix__row:hover {
background: var(--surface);
}
.mix__swatch {
width: 11px;
height: 11px;
border-radius: 3px;
flex-shrink: 0;
}
.mix__name {
font-size: 13px;
font-weight: 600;
flex: 1;
}
.mix__count {
font-size: 13px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.mix__pct {
font-size: 12px;
color: var(--muted);
font-weight: 700;
min-width: 38px;
text-align: right;
}
/* ---------- Cohort heat grid ---------- */
.scale {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--muted);
font-weight: 600;
flex-shrink: 0;
}
.scale__bar {
width: 90px;
height: 10px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(198, 255, 58, 0.12), var(--neon));
}
.cohort {
display: grid;
gap: 4px;
overflow-x: auto;
}
.cohort__head,
.cohort__row {
display: grid;
grid-template-columns: 92px repeat(var(--cols, 6), 1fr);
gap: 4px;
align-items: center;
min-width: 460px;
}
.cohort__corner,
.cohort__colhead {
font-size: 11px;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
text-align: center;
padding-bottom: 2px;
}
.cohort__corner {
text-align: left;
}
.cohort__label {
font-size: 12px;
font-weight: 700;
color: var(--ink-2);
}
.cell {
height: 38px;
border-radius: 8px;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 800;
color: #0d0f12;
cursor: default;
transition: transform 0.12s, box-shadow 0.12s;
font-variant-numeric: tabular-nums;
}
.cell:hover {
transform: scale(1.06);
box-shadow: 0 0 0 2px var(--line-2);
z-index: 2;
}
.cell--empty {
background: var(--surface) !important;
color: transparent;
cursor: default;
}
.cell--empty:hover {
transform: none;
box-shadow: none;
}
/* ---------- Top classes bars ---------- */
.bars {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
.bar__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.bar__name {
font-size: 13.5px;
font-weight: 700;
}
.bar__sub {
font-size: 11px;
color: var(--muted);
font-weight: 600;
margin-left: 8px;
}
.bar__val {
font-size: 13.5px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.bar__track {
height: 10px;
background: var(--surface);
border-radius: 999px;
overflow: hidden;
border: 1px solid var(--line);
}
.bar__fill {
height: 100%;
border-radius: 999px;
width: 0;
background: linear-gradient(90deg, var(--neon-d), var(--neon));
transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.bars li:nth-child(2) .bar__fill {
background: linear-gradient(90deg, #c98f2a, var(--orange));
}
.bars li:nth-child(3) .bar__fill {
background: linear-gradient(90deg, #6aa3ff, #9ec2ff);
}
/* ---------- Footer + toast ---------- */
.foot {
margin-top: 26px;
text-align: center;
font-size: 12px;
color: var(--muted);
}
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 20px);
background: var(--elevated);
border: 1px solid var(--line-2);
color: var(--ink);
font-weight: 600;
font-size: 13.5px;
padding: 11px 18px;
border-radius: 12px;
box-shadow: var(--sh-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
z-index: 50;
}
.toast.is-show {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.kpis {
grid-template-columns: repeat(2, 1fr);
}
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.shell {
padding: 16px 12px 40px;
}
.topbar {
gap: 12px;
}
.topbar__right {
width: 100%;
justify-content: space-between;
}
.kpis {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.kpi__value {
font-size: 22px;
}
.panel {
padding: 14px;
}
.panel__head {
flex-direction: column;
}
.legend {
margin-top: 2px;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.chart__tip[hidden] {
display: none;
}(function () {
"use strict";
var SVGNS = "http://www.w3.org/2000/svg";
/* ---------------- Toast ---------------- */
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-show");
}, 2200);
}
/* ---------------- Formatting ---------------- */
function money(n) {
if (n >= 1000) return "$" + (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return "$" + Math.round(n).toLocaleString();
}
function moneyFull(n) {
return "$" + Math.round(n).toLocaleString();
}
/* ---------------- Synthetic but deterministic data ---------------- */
// seeded pseudo-random so charts look organic yet stable per range
function seeded(seed) {
var s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return function () {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
// Build a revenue series of N points for a range key.
function buildSeries(rangeKey) {
var cfg = {
"30d": { points: 30, base: 1480, growth: 0.0022, label: "30 days", seed: 11 },
"90d": { points: 13, base: 38000, growth: 0.012, label: "90 days", seed: 23 },
"12m": { points: 12, base: 142000, growth: 0.028, label: "12 months", seed: 41 }
}[rangeKey];
var rnd = seeded(cfg.seed);
var pts = [];
var val = cfg.base;
for (var i = 0; i < cfg.points; i++) {
var noise = (rnd() - 0.4) * cfg.base * 0.06;
val = val * (1 + cfg.growth) + noise;
var total = Math.max(cfg.base * 0.7, val);
pts.push({
i: i,
total: total,
recurring: total * (0.72 + rnd() * 0.08),
label: pointLabel(rangeKey, i, cfg.points)
});
}
return { pts: pts, label: cfg.label };
}
function pointLabel(rangeKey, i, count) {
if (rangeKey === "12m") {
var months = ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "Jan", "Feb", "Mar", "Apr", "May", "Jun"];
return months[i % 12];
}
if (rangeKey === "90d") {
return "Wk " + (i + 1);
}
return "Day " + (i + 1);
}
// KPI snapshots per range
var KPI = {
"30d": {
mrr: { value: 48200, delta: 4.1, fmt: money },
arpu: { value: 61, delta: 1.8, fmt: moneyFull },
churn: { value: 3.4, delta: -0.3, fmt: pct, lowerBetter: true },
ltv: { value: 1790, delta: 2.6, fmt: money }
},
"90d": {
mrr: { value: 51640, delta: 7.2, fmt: money },
arpu: { value: 63, delta: 2.4, fmt: moneyFull },
churn: { value: 3.1, delta: -0.6, fmt: pct, lowerBetter: true },
ltv: { value: 1965, delta: 5.1, fmt: money }
},
"12m": {
mrr: { value: 54900, delta: 22.5, fmt: money },
arpu: { value: 66, delta: 9.3, fmt: moneyFull },
churn: { value: 2.8, delta: -1.4, fmt: pct, lowerBetter: true },
ltv: { value: 2240, delta: 18.7, fmt: money }
}
};
function pct(n) {
return n.toFixed(1) + "%";
}
var MIX = [
{ name: "Unlimited", count: 412, color: "#c6ff3a" },
{ name: "Standard", count: 286, color: "#ff6a2b" },
{ name: "Class Pass", count: 174, color: "#6aa3ff" },
{ name: "Off-Peak", count: 98, color: "#34d399" },
{ name: "Student", count: 63, color: "#fbbf24" }
];
var CLASSES = [
{ name: "Hyrox Conditioning", coach: "M. Okafor", rev: 18400 },
{ name: "Olympic Lifting", coach: "S. Vargas", rev: 14250 },
{ name: "Spin Inferno", coach: "L. Petrov", rev: 11900 },
{ name: "Mobility & Recovery", coach: "D. Reyes", rev: 7350 },
{ name: "Open Mat BJJ", coach: "K. Tanaka", rev: 5120 }
];
// Cohort retention: rows = join cohort, cols = months since join
var COHORTS = [
{ name: "Jan '26", vals: [100, 88, 79, 71, 66, 62] },
{ name: "Feb '26", vals: [100, 90, 82, 75, 69, null] },
{ name: "Mar '26", vals: [100, 87, 80, 73, null, null] },
{ name: "Apr '26", vals: [100, 91, 84, null, null, null] },
{ name: "May '26", vals: [100, 89, null, null, null, null] },
{ name: "Jun '26", vals: [100, null, null, null, null, null] }
];
/* ---------------- KPI rendering ---------------- */
function renderKPIs(rangeKey) {
var set = KPI[rangeKey];
document.querySelectorAll(".kpi").forEach(function (card) {
var key = card.getAttribute("data-kpi");
var m = set[key];
card.querySelector('[data-field="value"]').textContent = m.fmt(m.value);
var d = card.querySelector('[data-field="delta"]');
var positive = m.lowerBetter ? m.delta < 0 : m.delta >= 0;
d.textContent = (m.delta >= 0 ? "+" : "") + m.delta.toFixed(1) + "%";
d.classList.toggle("is-up", positive);
d.classList.toggle("is-down", !positive);
drawSpark(card.querySelector("[data-spark]"), key, rangeKey, positive);
});
}
function drawSpark(svg, key, rangeKey, positive) {
if (!svg) return;
svg.innerHTML = "";
var rnd = seeded(key.charCodeAt(0) + rangeKey.length * 7);
var n = 16;
var W = 120;
var H = 36;
var vals = [];
var v = 0.5;
for (var i = 0; i < n; i++) {
v += (rnd() - (positive ? 0.4 : 0.6)) * 0.18;
v = Math.max(0.08, Math.min(0.95, v));
vals.push(v);
}
var pts = vals.map(function (val, i) {
return [(i / (n - 1)) * W, H - val * H];
});
var d = "M" + pts.map(function (p) { return p[0].toFixed(1) + "," + p[1].toFixed(1); }).join(" L");
var area = d + " L" + W + "," + H + " L0," + H + " Z";
var color = positive ? "var(--neon)" : "var(--danger)";
var fill = el("path", { d: area, fill: positive ? "var(--neon-50)" : "rgba(248,113,113,0.12)" });
var line = el("path", { d: d, fill: "none", stroke: color, "stroke-width": "2", "stroke-linejoin": "round", "stroke-linecap": "round" });
svg.appendChild(fill);
svg.appendChild(line);
}
/* ---------------- SVG helper ---------------- */
function el(name, attrs) {
var n = document.createElementNS(SVGNS, name);
for (var k in attrs) n.setAttribute(k, attrs[k]);
return n;
}
/* ---------------- Revenue area/line chart ---------------- */
var revChart = document.getElementById("revChart");
var revSvg = revChart ? revChart.querySelector("svg") : null;
var revTip = document.getElementById("revTip");
var currentSeries = null;
var chartGeom = null;
function renderRevenue(rangeKey) {
var data = buildSeries(rangeKey);
currentSeries = data;
document.querySelectorAll("[data-range-label]").forEach(function (n) {
n.textContent = data.label;
});
if (!revSvg) return;
revSvg.innerHTML = "";
var W = 720, H = 280;
var padL = 52, padR = 14, padT = 16, padB = 30;
var iw = W - padL - padR;
var ih = H - padT - padB;
var pts = data.pts;
var max = 0;
pts.forEach(function (p) { if (p.total > max) max = p.total; });
max = max * 1.12;
function x(i) { return padL + (i / (pts.length - 1)) * iw; }
function y(v) { return padT + ih - (v / max) * ih; }
// gridlines + y labels
var ticks = 4;
for (var t = 0; t <= ticks; t++) {
var gv = (max / ticks) * t;
var gy = y(gv);
revSvg.appendChild(el("line", { x1: padL, y1: gy, x2: W - padR, y2: gy, class: "axis-line" }));
var lbl = el("text", { x: padL - 8, y: gy + 4, "text-anchor": "end", class: "axis-label" });
lbl.textContent = money(gv);
revSvg.appendChild(lbl);
}
// x labels (sparse)
var step = Math.ceil(pts.length / 6);
pts.forEach(function (p, i) {
if (i % step === 0 || i === pts.length - 1) {
var tx = el("text", { x: x(i), y: H - 8, "text-anchor": "middle", class: "axis-label" });
tx.textContent = p.label;
revSvg.appendChild(tx);
}
});
// line paths
function pathFor(field) {
return "M" + pts.map(function (p, i) { return x(i).toFixed(1) + "," + y(p[field]).toFixed(1); }).join(" L");
}
var totalPath = pathFor("total");
var recPath = pathFor("recurring");
// total area
var areaD = totalPath + " L" + x(pts.length - 1).toFixed(1) + "," + (padT + ih) + " L" + x(0).toFixed(1) + "," + (padT + ih) + " Z";
// gradient
var defs = el("defs", {});
defs.innerHTML =
'<linearGradient id="revGrad" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0%" stop-color="rgba(198,255,58,0.28)"/>' +
'<stop offset="100%" stop-color="rgba(198,255,58,0.01)"/>' +
"</linearGradient>";
revSvg.appendChild(defs);
revSvg.appendChild(el("path", { d: areaD, fill: "url(#revGrad)" }));
revSvg.appendChild(el("path", { d: recPath, fill: "none", stroke: "var(--orange)", "stroke-width": "2.5", "stroke-linejoin": "round", "stroke-linecap": "round", "stroke-dasharray": "4 4" }));
revSvg.appendChild(el("path", { d: totalPath, fill: "none", stroke: "var(--neon)", "stroke-width": "3", "stroke-linejoin": "round", "stroke-linecap": "round" }));
// hover layer
var hLine = el("line", { x1: 0, y1: padT, x2: 0, y2: padT + ih, class: "hover-line", opacity: "0" });
var dotT = el("circle", { r: "4.5", fill: "var(--neon)", class: "hover-dot", opacity: "0" });
var dotR = el("circle", { r: "4", fill: "var(--orange)", class: "hover-dot", opacity: "0" });
revSvg.appendChild(hLine);
revSvg.appendChild(dotT);
revSvg.appendChild(dotR);
var overlay = el("rect", { x: padL, y: padT, width: iw, height: ih, fill: "transparent", style: "cursor:crosshair" });
revSvg.appendChild(overlay);
chartGeom = { x: x, y: y, pts: pts, padL: padL, iw: iw, hLine: hLine, dotT: dotT, dotR: dotR, overlay: overlay };
overlay.addEventListener("mousemove", onRevMove);
overlay.addEventListener("mouseleave", function () {
hLine.setAttribute("opacity", "0");
dotT.setAttribute("opacity", "0");
dotR.setAttribute("opacity", "0");
if (revTip) revTip.hidden = true;
});
}
function onRevMove(e) {
if (!chartGeom) return;
var rect = revSvg.getBoundingClientRect();
var scale = 720 / rect.width;
var px = (e.clientX - rect.left) * scale;
var rel = (px - chartGeom.padL) / chartGeom.iw;
var idx = Math.round(rel * (chartGeom.pts.length - 1));
idx = Math.max(0, Math.min(chartGeom.pts.length - 1, idx));
var p = chartGeom.pts[idx];
var cx = chartGeom.x(idx);
chartGeom.hLine.setAttribute("x1", cx);
chartGeom.hLine.setAttribute("x2", cx);
chartGeom.hLine.setAttribute("opacity", "1");
chartGeom.dotT.setAttribute("cx", cx);
chartGeom.dotT.setAttribute("cy", chartGeom.y(p.total));
chartGeom.dotT.setAttribute("opacity", "1");
chartGeom.dotR.setAttribute("cx", cx);
chartGeom.dotR.setAttribute("cy", chartGeom.y(p.recurring));
chartGeom.dotR.setAttribute("opacity", "1");
if (revTip) {
revTip.hidden = false;
revTip.innerHTML =
"<b>" + p.label + "</b>" +
'<div class="tip-row"><i class="dot dot--neon"></i>' + moneyFull(p.total) + "</div>" +
'<div class="tip-row"><i class="dot dot--orange"></i>' + moneyFull(p.recurring) + "</div>";
var leftPct = (cx / 720) * 100;
revTip.style.left = leftPct + "%";
revTip.style.top = (chartGeom.y(p.total) / 280) * 100 + "%";
}
}
/* ---------------- Donut ---------------- */
function renderDonut() {
var g = document.getElementById("donutG");
if (!g) return;
g.innerHTML = "";
var total = MIX.reduce(function (s, m) { return s + m.count; }, 0);
document.getElementById("donutTotal").textContent = total.toLocaleString();
var R = 72, r = 48;
var start = -Math.PI / 2;
MIX.forEach(function (m, idx) {
var frac = m.count / total;
var end = start + frac * Math.PI * 2;
var path = el("path", {
d: arcPath(0, 0, R, r, start, end),
fill: m.color,
class: "arc",
"data-idx": idx
});
path.addEventListener("mouseenter", function () {
g.querySelectorAll(".arc").forEach(function (a, i) {
a.classList.toggle("is-dim", i !== idx);
});
document.getElementById("donutTotal").textContent = Math.round(frac * 100) + "%";
document.querySelector(".donut__label").textContent = m.name;
});
path.addEventListener("mouseleave", function () {
g.querySelectorAll(".arc").forEach(function (a) { a.classList.remove("is-dim"); });
document.getElementById("donutTotal").textContent = total.toLocaleString();
document.querySelector(".donut__label").textContent = "members";
});
g.appendChild(path);
start = end;
});
// mix list
var list = document.getElementById("mixList");
list.innerHTML = "";
MIX.forEach(function (m) {
var li = document.createElement("li");
li.className = "mix__row";
li.innerHTML =
'<span class="mix__swatch" style="background:' + m.color + '"></span>' +
'<span class="mix__name">' + m.name + "</span>" +
'<span class="mix__count">' + m.count + "</span>" +
'<span class="mix__pct">' + Math.round((m.count / total) * 100) + "%</span>";
list.appendChild(li);
});
}
function arcPath(cx, cy, R, r, a0, a1) {
var large = a1 - a0 > Math.PI ? 1 : 0;
var x0 = cx + R * Math.cos(a0), y0 = cy + R * Math.sin(a0);
var x1 = cx + R * Math.cos(a1), y1 = cy + R * Math.sin(a1);
var x2 = cx + r * Math.cos(a1), y2 = cy + r * Math.sin(a1);
var x3 = cx + r * Math.cos(a0), y3 = cy + r * Math.sin(a0);
return (
"M" + x0.toFixed(2) + "," + y0.toFixed(2) +
" A" + R + "," + R + " 0 " + large + " 1 " + x1.toFixed(2) + "," + y1.toFixed(2) +
" L" + x2.toFixed(2) + "," + y2.toFixed(2) +
" A" + r + "," + r + " 0 " + large + " 0 " + x3.toFixed(2) + "," + y3.toFixed(2) +
" Z"
);
}
/* ---------------- Cohort heat grid ---------------- */
function renderCohort() {
var wrap = document.getElementById("cohort");
if (!wrap) return;
wrap.innerHTML = "";
var cols = COHORTS[0].vals.length;
wrap.style.setProperty("--cols", cols);
// header
var head = document.createElement("div");
head.className = "cohort__head";
head.innerHTML = '<div class="cohort__corner">Cohort</div>';
for (var c = 0; c < cols; c++) {
var ch = document.createElement("div");
ch.className = "cohort__colhead";
ch.textContent = "M" + c;
head.appendChild(ch);
}
wrap.appendChild(head);
COHORTS.forEach(function (co) {
var row = document.createElement("div");
row.className = "cohort__row";
var lbl = document.createElement("div");
lbl.className = "cohort__label";
lbl.textContent = co.name;
row.appendChild(lbl);
co.vals.forEach(function (v, mi) {
var cell = document.createElement("div");
if (v === null) {
cell.className = "cell cell--empty";
cell.textContent = "·";
} else {
cell.className = "cell";
cell.style.background = heatColor(v);
cell.style.color = v < 45 ? "var(--ink)" : "#0d0f12";
cell.textContent = v + "%";
cell.title = co.name + " · month " + mi + ": " + v + "% retained";
}
row.appendChild(cell);
});
wrap.appendChild(row);
});
}
// 0..100 -> neon intensity
function heatColor(v) {
var t = v / 100;
var alpha = 0.14 + t * 0.86;
// blend from dark neon to bright neon
return "rgba(198,255,58," + alpha.toFixed(2) + ")";
}
/* ---------------- Top classes bars ---------------- */
function renderClasses() {
var ul = document.getElementById("classBars");
if (!ul) return;
ul.innerHTML = "";
var max = Math.max.apply(null, CLASSES.map(function (c) { return c.rev; }));
CLASSES.forEach(function (c) {
var li = document.createElement("li");
li.innerHTML =
'<div class="bar__top">' +
'<span class="bar__name">' + c.name + '<span class="bar__sub">' + c.coach + "</span></span>" +
'<span class="bar__val">' + moneyFull(c.rev) + "</span>" +
"</div>" +
'<div class="bar__track"><div class="bar__fill"></div></div>';
ul.appendChild(li);
var fill = li.querySelector(".bar__fill");
// animate in next frame
requestAnimationFrame(function () {
requestAnimationFrame(function () {
fill.style.width = (c.rev / max) * 100 + "%";
});
});
});
}
/* ---------------- Range toggle ---------------- */
var currentRange = "90d";
function setRange(rangeKey) {
currentRange = rangeKey;
document.querySelectorAll(".range__btn").forEach(function (b) {
var on = b.getAttribute("data-range") === rangeKey;
b.classList.toggle("is-active", on);
if (on) b.setAttribute("aria-pressed", "true");
else b.removeAttribute("aria-pressed");
});
renderKPIs(rangeKey);
renderRevenue(rangeKey);
}
document.querySelectorAll(".range__btn").forEach(function (b) {
b.addEventListener("click", function () {
var rk = b.getAttribute("data-range");
if (rk === currentRange) return;
setRange(rk);
toast(currentSeries ? "Range updated · " + currentSeries.label : "Range updated");
});
});
var exportBtn = document.getElementById("exportBtn");
if (exportBtn) {
exportBtn.addEventListener("click", function () {
toast("Report queued · CSV will arrive by email");
});
}
/* ---------------- Init ---------------- */
renderDonut();
renderCohort();
renderClasses();
setRange(currentRange);
// redraw revenue on resize (tooltip geometry depends on width)
var rzTimer;
window.addEventListener("resize", function () {
clearTimeout(rzTimer);
rzTimer = setTimeout(function () {
renderRevenue(currentRange);
}, 150);
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Iron Republic — Revenue & Retention</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="shell">
<!-- Topbar -->
<header class="topbar">
<div class="brand">
<div class="brand__mark" aria-hidden="true">IR</div>
<div class="brand__txt">
<span class="brand__name">Iron Republic</span>
<span class="brand__sub">Admin · Revenue & Retention</span>
</div>
</div>
<div class="topbar__right">
<div class="range" role="group" aria-label="Date range">
<button class="range__btn" data-range="30d" type="button">30d</button>
<button class="range__btn is-active" data-range="90d" type="button" aria-pressed="true">90d</button>
<button class="range__btn" data-range="12m" type="button">12m</button>
</div>
<button class="export" type="button" id="exportBtn">Export</button>
</div>
</header>
<!-- KPI cards -->
<section class="kpis" aria-label="Key metrics">
<article class="kpi" data-kpi="mrr">
<span class="kpi__eyebrow">Monthly recurring revenue</span>
<div class="kpi__row">
<span class="kpi__value" data-field="value">$0</span>
<span class="kpi__delta" data-field="delta">+0%</span>
</div>
<div class="kpi__spark"><svg viewBox="0 0 120 36" preserveAspectRatio="none" data-spark></svg></div>
</article>
<article class="kpi" data-kpi="arpu">
<span class="kpi__eyebrow">Avg revenue / member</span>
<div class="kpi__row">
<span class="kpi__value" data-field="value">$0</span>
<span class="kpi__delta" data-field="delta">+0%</span>
</div>
<div class="kpi__spark"><svg viewBox="0 0 120 36" preserveAspectRatio="none" data-spark></svg></div>
</article>
<article class="kpi" data-kpi="churn">
<span class="kpi__eyebrow">Monthly churn</span>
<div class="kpi__row">
<span class="kpi__value" data-field="value">0%</span>
<span class="kpi__delta" data-field="delta">+0%</span>
</div>
<div class="kpi__spark"><svg viewBox="0 0 120 36" preserveAspectRatio="none" data-spark></svg></div>
</article>
<article class="kpi" data-kpi="ltv">
<span class="kpi__eyebrow">Lifetime value</span>
<div class="kpi__row">
<span class="kpi__value" data-field="value">$0</span>
<span class="kpi__delta" data-field="delta">+0%</span>
</div>
<div class="kpi__spark"><svg viewBox="0 0 120 36" preserveAspectRatio="none" data-spark></svg></div>
</article>
</section>
<!-- Main grid -->
<section class="grid">
<!-- Revenue chart -->
<article class="panel panel--wide">
<header class="panel__head">
<div>
<h2 class="panel__title">Revenue over time</h2>
<p class="panel__hint" id="revHint">Total & recurring revenue, <span data-range-label>90 days</span></p>
</div>
<div class="legend">
<span class="legend__item"><i class="dot dot--neon"></i>Total</span>
<span class="legend__item"><i class="dot dot--orange"></i>Recurring</span>
</div>
</header>
<div class="chart" id="revChart">
<svg viewBox="0 0 720 280" preserveAspectRatio="none" role="img" aria-label="Revenue over time area chart"></svg>
<div class="chart__tip" id="revTip" hidden></div>
</div>
</article>
<!-- Membership mix donut -->
<article class="panel">
<header class="panel__head">
<div>
<h2 class="panel__title">Membership mix</h2>
<p class="panel__hint">Active members by plan</p>
</div>
</header>
<div class="donut">
<svg viewBox="0 0 180 180" role="img" aria-label="Membership mix donut chart">
<g id="donutG" transform="translate(90,90)"></g>
</svg>
<div class="donut__center">
<span class="donut__total" id="donutTotal">0</span>
<span class="donut__label">members</span>
</div>
</div>
<ul class="mix" id="mixList"></ul>
</article>
<!-- Cohort retention heat grid -->
<article class="panel panel--wide">
<header class="panel__head">
<div>
<h2 class="panel__title">Cohort retention</h2>
<p class="panel__hint">% of each joining cohort still active by month</p>
</div>
<div class="scale">
<span>Low</span>
<span class="scale__bar" aria-hidden="true"></span>
<span>High</span>
</div>
</header>
<div class="cohort" id="cohort"></div>
</article>
<!-- Top classes by revenue -->
<article class="panel">
<header class="panel__head">
<div>
<h2 class="panel__title">Top classes by revenue</h2>
<p class="panel__hint">Attributed member spend</p>
</div>
</header>
<ul class="bars" id="classBars"></ul>
</article>
</section>
<footer class="foot">
<span>Iron Republic Admin · illustrative data, not a real product</span>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Revenue & Retention Dashboard
A dark, high-energy admin view for Iron Republic, a fictional performance gym. The top strip shows four KPI cards — monthly recurring revenue, average revenue per member, monthly churn and lifetime value — each with a colored trend delta (green for good, red for bad, with churn treated as lower-is-better) and a small inline sparkline. Below them, a wide hand-drawn SVG chart plots total and recurring revenue over time, with axis gridlines, a crosshair and a hover tooltip that reads off the nearest data point.
The right rail holds a membership-mix donut built from SVG arc paths: hovering a slice dims the others and swaps the center label to that plan’s share, while a synced legend lists each plan’s count and percentage. A full-width cohort-retention heat grid maps joining cohorts against months since signup, with cell color intensity scaling to the retained percentage and per-cell tooltips. A top-classes-by-revenue list animates gradient bars proportional to attributed member spend.
The 30d / 90d / 12m range toggle re-renders the KPIs, sparklines and revenue chart with range-specific figures, and an Export action fires a toast. Everything is plain vanilla JS — all charts are drawn by hand in SVG with no external libraries.
Illustrative UI only — figures and members are fictional, not a real product.