Gym — Class Schedule
A bold, dark-themed weekly class schedule for a performance gym. A scrollable seven-day grid with sticky day and time headers shows color-coded class blocks for HIIT, Vinyasa, Spin and more, each with trainer, room, intensity tag and live spots-left. Filter chips dim non-matching types, the current day column is highlighted, and a week navigator steps between weeks. Tapping any block opens a slide-in detail panel with a Book button that decrements spots and toggles to Booked.
MCP
Kod
: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 24px 60px rgba(0,0,0,0.6);
/* type colors */
--c-strength:#ff6a2b; --c-strength-bg:rgba(255,106,43,0.13);
--c-cardio:#f87171; --c-cardio-bg:rgba(248,113,113,0.13);
--c-yoga:#34d399; --c-yoga-bg:rgba(52,211,153,0.13);
--c-cycle:#60a5fa; --c-cycle-bg:rgba(96,165,250,0.13);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background:
radial-gradient(900px 500px at 88% -10%, rgba(198,255,58,0.06), transparent 60%),
radial-gradient(700px 500px at -5% 0%, rgba(255,106,43,0.05), transparent 55%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
button { font-family: inherit; cursor: pointer; }
:focus-visible { outline: 2px solid var(--neon); outline-offset: 2px; border-radius: 6px; }
.app { max-width: 1240px; margin: 0 auto; padding: 22px clamp(14px, 3vw, 28px) 60px; }
/* ===== Topbar ===== */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 18px; flex-wrap: wrap; margin-bottom: 18px;
}
.brand { display: flex; align-items: center; gap: 14px; }
.brand-mark {
width: 46px; height: 46px; display: grid; place-items: center;
background: var(--neon); color: #0c1500; font-weight: 900; font-size: 24px;
border-radius: 13px; box-shadow: var(--sh-2), inset 0 0 0 1px rgba(255,255,255,0.2);
transform: rotate(-4deg);
}
.brand-eyebrow {
display: block; font-size: 11px; font-weight: 800; letter-spacing: 0.16em;
text-transform: uppercase; color: var(--muted);
}
.brand-title { margin: 1px 0 0; font-size: 26px; font-weight: 900; letter-spacing: -0.02em; }
.week-nav { display: flex; align-items: center; gap: 8px; }
.wk-btn {
width: 40px; height: 40px; display: grid; place-items: center;
background: var(--surface); color: var(--ink); border: 1px solid var(--line);
border-radius: var(--r-sm); transition: background .15s, border-color .15s, transform .1s;
}
.wk-btn:hover { background: var(--surface-2); border-color: var(--line-2); }
.wk-btn:active { transform: scale(.94); }
.wk-range {
min-width: 172px; text-align: center; font-weight: 700; font-size: 14px;
padding: 9px 14px; background: var(--surface); border: 1px solid var(--line);
border-radius: var(--r-sm); color: var(--ink-2);
}
.wk-today {
padding: 9px 18px; font-weight: 800; font-size: 13px; letter-spacing: .03em;
text-transform: uppercase; color: #0c1500; background: var(--neon);
border: none; border-radius: var(--r-sm); box-shadow: var(--sh-1);
transition: background .15s, transform .1s;
}
.wk-today:hover { background: var(--neon-d); }
.wk-today:active { transform: scale(.96); }
/* ===== Filters ===== */
.filters {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 14px; margin-bottom: 16px;
background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md);
}
.filters-label {
font-size: 11px; font-weight: 800; letter-spacing: .16em; text-transform: uppercase;
color: var(--muted);
}
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 14px; font-weight: 700; font-size: 13px;
color: var(--ink-2); background: var(--surface-2);
border: 1px solid var(--line); border-radius: 999px;
transition: all .15s;
}
.chip:hover { border-color: var(--line-2); color: var(--ink); }
.chip.is-active {
color: #0c1500; background: var(--neon); border-color: var(--neon);
box-shadow: var(--sh-1);
}
.chip.is-active .dot { box-shadow: 0 0 0 2px rgba(0,0,0,0.25); }
.dot { width: 9px; height: 9px; border-radius: 50%; }
.dot-strength { background: var(--c-strength); }
.dot-cardio { background: var(--c-cardio); }
.dot-yoga { background: var(--c-yoga); }
.dot-cycle { background: var(--c-cycle); }
.legend { display: flex; gap: 8px; margin-left: auto; }
.leg {
font-size: 11px; font-weight: 700; padding: 4px 10px; border-radius: 999px;
text-transform: uppercase; letter-spacing: .04em;
}
.leg-low { color: var(--ok); background: rgba(52,211,153,0.12); }
.leg-mid { color: var(--warn); background: rgba(251,191,36,0.12); }
.leg-high { color: var(--danger); background: rgba(248,113,113,0.12); }
/* ===== Grid ===== */
.grid-wrap {
overflow: auto; border: 1px solid var(--line); border-radius: var(--r-lg);
background: var(--surface); box-shadow: var(--sh-2);
max-height: 74vh; -webkit-overflow-scrolling: touch;
}
.grid {
display: grid;
grid-template-columns: 64px repeat(7, minmax(132px, 1fr));
min-width: 920px;
}
.corner {
position: sticky; top: 0; left: 0; z-index: 6;
background: var(--surface-2); border-bottom: 1px solid var(--line); border-right: 1px solid var(--line);
font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase;
color: var(--muted); display: grid; place-items: center;
}
.dayhead {
position: sticky; top: 0; z-index: 5;
background: var(--surface-2); border-bottom: 1px solid var(--line);
padding: 12px 10px; text-align: center; border-left: 1px solid var(--line);
}
.dayhead .d-name {
display: block; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em;
}
.dayhead .d-date { display: block; font-size: 12px; color: var(--muted); margin-top: 2px; font-weight: 600; }
.dayhead.is-today {
background: linear-gradient(180deg, var(--neon-50), transparent);
border-bottom: 2px solid var(--neon);
}
.dayhead.is-today .d-name { color: var(--neon); }
.timecell {
position: sticky; left: 0; z-index: 4;
background: var(--surface); border-right: 1px solid var(--line); border-top: 1px solid var(--line);
font-size: 11px; font-weight: 700; color: var(--muted);
padding: 8px 6px 0; text-align: right; min-height: 92px;
}
.slot {
border-top: 1px solid var(--line); border-left: 1px solid var(--line);
padding: 6px; min-height: 92px; position: relative;
}
.slot.is-today-col { background: rgba(198,255,58,0.035); }
/* class block */
.block {
width: 100%; text-align: left; display: block;
padding: 8px 9px; border-radius: var(--r-sm);
background: var(--surface-2); border: 1px solid var(--line);
border-left: 3px solid var(--muted);
transition: transform .12s, box-shadow .15s, border-color .15s;
position: relative; overflow: hidden;
}
.block:hover { transform: translateY(-2px); box-shadow: var(--sh-2); border-color: var(--line-2); }
.block.is-selected { box-shadow: 0 0 0 2px var(--neon); }
.block[data-type="Strength"] { border-left-color: var(--c-strength); background: linear-gradient(180deg, var(--c-strength-bg), var(--surface-2)); }
.block[data-type="Cardio"] { border-left-color: var(--c-cardio); background: linear-gradient(180deg, var(--c-cardio-bg), var(--surface-2)); }
.block[data-type="Yoga"] { border-left-color: var(--c-yoga); background: linear-gradient(180deg, var(--c-yoga-bg), var(--surface-2)); }
.block[data-type="Cycle"] { border-left-color: var(--c-cycle); background: linear-gradient(180deg, var(--c-cycle-bg), var(--surface-2)); }
.block.is-booked::after {
content: "Booked"; position: absolute; top: 6px; right: -22px;
transform: rotate(34deg); background: var(--neon); color: #0c1500;
font-size: 9px; font-weight: 900; letter-spacing: .05em; padding: 2px 22px;
}
.b-name { font-size: 13px; font-weight: 800; letter-spacing: -.01em; margin: 0; line-height: 1.2; }
.b-meta { font-size: 11px; color: var(--ink-2); margin: 3px 0 0; font-weight: 600; }
.b-foot { display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
.b-int {
font-size: 9px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em;
padding: 2px 7px; border-radius: 999px;
}
.int-low { color: var(--ok); background: rgba(52,211,153,0.14); }
.int-mid { color: var(--warn); background: rgba(251,191,36,0.14); }
.int-high { color: var(--danger); background: rgba(248,113,113,0.14); }
.b-spots { font-size: 10px; font-weight: 700; color: var(--muted); }
.b-spots.is-low { color: var(--warn); }
.b-spots.is-full { color: var(--danger); }
.block.dim { opacity: .22; filter: saturate(.4); pointer-events: none; }
/* ===== Detail panel ===== */
.panel-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.55);
backdrop-filter: blur(2px); z-index: 40; animation: fade .18s ease;
}
@keyframes fade { from { opacity: 0; } }
.panel {
position: fixed; top: 0; right: 0; height: 100%; width: min(400px, 92vw);
background: var(--surface); border-left: 1px solid var(--line-2);
box-shadow: var(--sh-3); z-index: 50;
transform: translateX(105%); transition: transform .26s cubic-bezier(.2,.8,.2,1);
display: flex; flex-direction: column;
}
.panel.is-open { transform: translateX(0); }
.panel-close {
position: absolute; top: 14px; right: 14px; z-index: 2;
width: 38px; height: 38px; display: grid; place-items: center;
background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--r-sm);
color: var(--ink); transition: background .15s;
}
.panel-close:hover { background: var(--elevated); }
.panel-body { padding: 26px 24px; overflow-y: auto; flex: 1; }
.pn-type {
display: inline-flex; align-items: center; gap: 7px;
font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .1em;
padding: 5px 11px; border-radius: 999px; margin-bottom: 14px;
}
.pn-title { font-size: 28px; font-weight: 900; letter-spacing: -.02em; margin: 0 0 4px; }
.pn-sub { color: var(--ink-2); font-weight: 600; margin: 0 0 20px; }
.pn-rows { display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; margin-bottom: 18px; }
.pn-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--surface-2); }
.pn-k { font-size: 12px; color: var(--muted); font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.pn-v { font-size: 14px; font-weight: 700; text-align: right; }
.pn-desc { color: var(--ink-2); font-size: 14px; line-height: 1.6; margin: 0 0 22px; }
.pn-spots-bar { height: 7px; border-radius: 999px; background: var(--surface-2); overflow: hidden; margin: 8px 0 22px; }
.pn-spots-fill { height: 100%; background: linear-gradient(90deg, var(--neon), var(--neon-d)); border-radius: 999px; transition: width .3s; }
.pn-spots-fill.is-low { background: linear-gradient(90deg, var(--warn), var(--orange)); }
.pn-spots-fill.is-full { background: var(--danger); }
.pn-book {
width: 100%; padding: 16px; font-size: 15px; font-weight: 900;
text-transform: uppercase; letter-spacing: .06em; color: #0c1500;
background: var(--neon); border: none; border-radius: var(--r-md);
box-shadow: var(--sh-2); transition: background .15s, transform .1s;
}
.pn-book:hover:not(:disabled) { background: var(--neon-d); }
.pn-book:active:not(:disabled) { transform: scale(.98); }
.pn-book.is-booked { background: var(--surface-2); color: var(--ok); box-shadow: none; border: 1px solid var(--ok); }
.pn-book:disabled { background: var(--surface-2); color: var(--muted); cursor: not-allowed; box-shadow: none; }
/* ===== Toast ===== */
.toast-host {
position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
z-index: 80; display: flex; flex-direction: column; gap: 10px; align-items: center;
pointer-events: none;
}
.toast {
background: var(--elevated); color: var(--ink); font-weight: 700; font-size: 14px;
padding: 13px 20px; border-radius: var(--r-md); border: 1px solid var(--line-2);
box-shadow: var(--sh-3); display: flex; align-items: center; gap: 10px;
animation: toastIn .25s ease;
}
.toast::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--neon); }
.toast.toast-out { animation: toastOut .3s ease forwards; }
@keyframes toastIn { from { transform: translateY(14px); opacity: 0; } }
@keyframes toastOut { to { transform: translateY(14px); opacity: 0; } }
/* ===== Responsive ===== */
@media (max-width: 520px) {
.app { padding: 16px 12px 48px; }
.brand-title { font-size: 22px; }
.week-nav { width: 100%; justify-content: space-between; }
.wk-range { min-width: 0; flex: 1; }
.filters { gap: 10px; }
.legend { margin-left: 0; width: 100%; }
.grid { grid-template-columns: 52px repeat(7, minmax(120px, 1fr)); min-width: 860px; }
.grid-wrap { max-height: 66vh; }
.panel { width: 100%; }
.pn-title { font-size: 24px; }
}(() => {
"use strict";
/* ---------- Data ---------- */
const TIMES = ["06:00", "07:30", "09:00", "12:00", "17:30", "18:45", "20:00"];
const ROOMS = ["Studio A", "Studio B", "The Box", "Spin Loft", "Mind Room"];
// type → intensity helper colors handled in CSS
// Each class: {day(0=Mon..6=Sun), time, name, type, trainer, room, intensity, capacity}
const RAW = [
{ day: 0, time: "06:00", name: "HIIT Burn", type: "Cardio", trainer: "Dana Reyes", room: "The Box", intensity: "high", capacity: 18 },
{ day: 0, time: "09:00", name: "Vinyasa Flow", type: "Yoga", trainer: "Mira Okafor", room: "Mind Room", intensity: "low", capacity: 22 },
{ day: 0, time: "17:30", name: "Spin45", type: "Cycle", trainer: "Leo Banks", room: "Spin Loft", intensity: "mid", capacity: 26 },
{ day: 0, time: "18:45", name: "Power Lift", type: "Strength", trainer: "Sam Cho", room: "Studio A", intensity: "high", capacity: 14 },
{ day: 1, time: "07:30", name: "Kettlebell Strong", type: "Strength", trainer: "Sam Cho", room: "Studio A", intensity: "mid", capacity: 16 },
{ day: 1, time: "12:00", name: "Express Core", type: "Cardio", trainer: "Dana Reyes", room: "Studio B", intensity: "mid", capacity: 20 },
{ day: 1, time: "18:45", name: "Restorative Yoga", type: "Yoga", trainer: "Mira Okafor", room: "Mind Room", intensity: "low", capacity: 22 },
{ day: 1, time: "20:00", name: "Night Ride", type: "Cycle", trainer: "Priya Nair", room: "Spin Loft", intensity: "high", capacity: 26 },
{ day: 2, time: "06:00", name: "Sunrise Spin", type: "Cycle", trainer: "Leo Banks", room: "Spin Loft", intensity: "mid", capacity: 26 },
{ day: 2, time: "09:00", name: "Mobility Mat", type: "Yoga", trainer: "Mira Okafor", room: "Mind Room", intensity: "low", capacity: 18 },
{ day: 2, time: "17:30", name: "Tabata Torch", type: "Cardio", trainer: "Dana Reyes", room: "The Box", intensity: "high", capacity: 18 },
{ day: 2, time: "18:45", name: "Deadlift Club", type: "Strength", trainer: "Sam Cho", room: "Studio A", intensity: "high", capacity: 12 },
{ day: 3, time: "07:30", name: "Boxing Cardio", type: "Cardio", trainer: "Marcus Hale", room: "The Box", intensity: "high", capacity: 18 },
{ day: 3, time: "12:00", name: "Lunch Flow", type: "Yoga", trainer: "Mira Okafor", room: "Mind Room", intensity: "low", capacity: 20 },
{ day: 3, time: "18:45", name: "Spin45", type: "Cycle", trainer: "Priya Nair", room: "Spin Loft", intensity: "mid", capacity: 26 },
{ day: 3, time: "20:00", name: "Functional Strength", type: "Strength", trainer: "Sam Cho", room: "Studio B", intensity: "mid", capacity: 16 },
{ day: 4, time: "06:00", name: "HIIT Burn", type: "Cardio", trainer: "Dana Reyes", room: "The Box", intensity: "high", capacity: 18 },
{ day: 4, time: "09:00", name: "Power Vinyasa", type: "Yoga", trainer: "Mira Okafor", room: "Mind Room", intensity: "mid", capacity: 22 },
{ day: 4, time: "17:30", name: "Friday Ride", type: "Cycle", trainer: "Leo Banks", room: "Spin Loft", intensity: "high", capacity: 26 },
{ day: 4, time: "18:45", name: "Total Body Lift", type: "Strength", trainer: "Sam Cho", room: "Studio A", intensity: "high", capacity: 14 },
{ day: 5, time: "09:00", name: "Weekend Warrior", type: "Strength", trainer: "Marcus Hale", room: "Studio A", intensity: "high", capacity: 16 },
{ day: 5, time: "12:00", name: "Spin & Sweat", type: "Cycle", trainer: "Priya Nair", room: "Spin Loft", intensity: "mid", capacity: 26 },
{ day: 5, time: "17:30", name: "Sunset Flow", type: "Yoga", trainer: "Mira Okafor", room: "Mind Room", intensity: "low", capacity: 24 },
{ day: 6, time: "09:00", name: "Recovery Stretch", type: "Yoga", trainer: "Mira Okafor", room: "Mind Room", intensity: "low", capacity: 24 },
{ day: 6, time: "12:00", name: "Steady Spin", type: "Cycle", trainer: "Leo Banks", room: "Spin Loft", intensity: "low", capacity: 26 },
];
// Deterministic-ish booked count per class so spots feel real
const classes = RAW.map((c, i) => {
const taken = Math.min(c.capacity, Math.round(((i * 7 + 11) % c.capacity) * 0.82) + 2);
return {
id: "c" + i,
...c,
booked: Math.min(taken, c.capacity),
mine: false,
};
});
const INTENSITY_LABEL = { low: "Low", mid: "Moderate", high: "High" };
const TYPE_COLORVAR = { Strength: "--c-strength", Cardio: "--c-cardio", Yoga: "--c-yoga", Cycle: "--c-cycle" };
/* ---------- State ---------- */
let activeFilter = "all";
let weekOffset = 0; // 0 = current week
let selectedId = null;
/* ---------- DOM ---------- */
const grid = document.getElementById("grid");
const weekRange = document.getElementById("weekRange");
const overlay = document.getElementById("overlay");
const panel = document.getElementById("panel");
const panelBody = document.getElementById("panelBody");
const toastHost = document.getElementById("toastHost");
let lastFocused = null;
/* ---------- Date helpers ---------- */
function mondayOf(offset) {
const d = new Date();
d.setHours(0, 0, 0, 0);
const dow = (d.getDay() + 6) % 7; // 0 = Mon
d.setDate(d.getDate() - dow + offset * 7);
return d;
}
function todayDayIndex() {
return (new Date().getDay() + 6) % 7;
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/* ---------- Render headers ---------- */
function renderHeaders() {
const mon = mondayOf(weekOffset);
const heads = grid.querySelectorAll(".dayhead");
const isCurrentWeek = weekOffset === 0;
const todayIdx = todayDayIndex();
heads.forEach((h, i) => {
const d = new Date(mon);
d.setDate(d.getDate() + i);
h.querySelector(".d-date").textContent = `${MONTHS[d.getMonth()]} ${d.getDate()}`;
h.classList.toggle("is-today", isCurrentWeek && i === todayIdx);
});
const last = new Date(mon); last.setDate(last.getDate() + 6);
if (isCurrentWeek) {
weekRange.textContent = "This Week";
} else if (weekOffset === 1) {
weekRange.textContent = "Next Week";
} else if (weekOffset === -1) {
weekRange.textContent = "Last Week";
} else {
weekRange.textContent = `${MONTHS[mon.getMonth()]} ${mon.getDate()} – ${MONTHS[last.getMonth()]} ${last.getDate()}`;
}
}
/* ---------- Render grid body ---------- */
function spotsLeft(c) { return c.capacity - c.booked; }
function blockHTML(c) {
const left = spotsLeft(c);
const spotCls = left === 0 ? "is-full" : left <= 3 ? "is-low" : "";
const spotTxt = left === 0 ? "Full" : `${left} spot${left === 1 ? "" : "s"} left`;
const dimCls = activeFilter !== "all" && c.type !== activeFilter ? "dim" : "";
const selCls = c.id === selectedId ? "is-selected" : "";
const bookedCls = c.mine ? "is-booked" : "";
return `<button class="block ${dimCls} ${selCls} ${bookedCls}" data-type="${c.type}" data-id="${c.id}" type="button"
aria-label="${c.name} with ${c.trainer} at ${c.time}, ${spotTxt}">
<p class="b-name">${c.name}</p>
<p class="b-meta">${c.time} · ${c.trainer.split(" ")[0]}</p>
<div class="b-foot">
<span class="b-int int-${c.intensity}">${INTENSITY_LABEL[c.intensity]}</span>
<span class="b-spots ${spotCls}">${spotTxt}</span>
</div>
</button>`;
}
function renderGrid() {
// remove existing rows (everything after the 8 header cells)
const headerCount = 8;
while (grid.children.length > headerCount) grid.removeChild(grid.lastChild);
const todayIdx = todayDayIndex();
const highlightCol = weekOffset === 0;
TIMES.forEach((time) => {
const tc = document.createElement("div");
tc.className = "timecell";
tc.textContent = time;
grid.appendChild(tc);
for (let day = 0; day < 7; day++) {
const slot = document.createElement("div");
slot.className = "slot" + (highlightCol && day === todayIdx ? " is-today-col" : "");
slot.setAttribute("role", "gridcell");
const match = classes.find((c) => c.day === day && c.time === time);
if (match) slot.innerHTML = blockHTML(match);
grid.appendChild(slot);
}
});
}
/* ---------- Detail panel ---------- */
function openPanel(c) {
selectedId = c.id;
const colorVar = TYPE_COLORVAR[c.type];
const left = spotsLeft(c);
const pct = Math.round((c.booked / c.capacity) * 100);
const fillCls = left === 0 ? "is-full" : left <= 3 ? "is-low" : "";
const bookLabel = c.mine ? "✓ Booked — Tap to cancel" : left === 0 ? "Class Full" : "Book This Class";
const bookCls = c.mine ? "is-booked" : "";
const bookDisabled = left === 0 && !c.mine ? "disabled" : "";
panelBody.innerHTML = `
<span class="pn-type" style="color:var(${colorVar});background:color-mix(in srgb, var(${colorVar}) 14%, transparent)">
<span class="dot" style="background:var(${colorVar})"></span>${c.type}
</span>
<h2 class="pn-title">${c.name}</h2>
<p class="pn-sub">with ${c.trainer}</p>
<div class="pn-rows">
<div class="pn-row"><span class="pn-k">Time</span><span class="pn-v">${c.time} – ${addMins(c.time, c.intensity === "low" ? 60 : 45)}</span></div>
<div class="pn-row"><span class="pn-k">Room</span><span class="pn-v">${c.room}</span></div>
<div class="pn-row"><span class="pn-k">Intensity</span><span class="pn-v"><span class="b-int int-${c.intensity}">${INTENSITY_LABEL[c.intensity]}</span></span></div>
<div class="pn-row"><span class="pn-k">Capacity</span><span class="pn-v">${c.booked}/${c.capacity}</span></div>
</div>
<div class="pn-spots-bar" aria-hidden="true">
<div class="pn-spots-fill ${fillCls}" style="width:${pct}%"></div>
</div>
<p class="pn-desc">${describe(c)}</p>
<button class="pn-book ${bookCls}" id="bookBtn" type="button" ${bookDisabled}>${bookLabel}</button>
`;
const bookBtn = document.getElementById("bookBtn");
if (bookBtn) bookBtn.addEventListener("click", () => toggleBooking(c));
lastFocused = document.activeElement;
overlay.hidden = false;
panel.classList.add("is-open");
panel.setAttribute("aria-hidden", "false");
panel.focus();
renderGrid(); // refresh selection highlight
}
function closePanel() {
panel.classList.remove("is-open");
panel.setAttribute("aria-hidden", "true");
overlay.hidden = true;
selectedId = null;
renderGrid();
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
function toggleBooking(c) {
if (!c.mine && spotsLeft(c) === 0) return;
if (c.mine) {
c.mine = false;
c.booked = Math.max(0, c.booked - 1);
toast(`Cancelled · ${c.name}`);
} else {
c.mine = true;
c.booked = Math.min(c.capacity, c.booked + 1);
toast(`Booked · ${c.name} @ ${c.time}`);
}
openPanel(c); // re-render panel + grid
}
function addMins(hhmm, mins) {
const [h, m] = hhmm.split(":").map(Number);
const total = h * 60 + m + mins;
const nh = Math.floor(total / 60) % 24;
const nm = total % 60;
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
}
function describe(c) {
const map = {
Strength: `A coach-led ${c.intensity === "high" ? "heavy" : "progressive"} strength session focused on compound lifts and controlled tempo. Bring a towel and water.`,
Cardio: `High-output conditioning built around intervals to spike your heart rate and torch calories. Scalable for all levels.`,
Yoga: `A breath-guided ${c.intensity === "low" ? "grounding" : "dynamic"} practice to build mobility, balance and recovery. Mats provided.`,
Cycle: `Beat-driven indoor ride with climbs, sprints and recovery flats. Cycling shoes available to borrow at the desk.`,
};
return map[c.type];
}
/* ---------- Toast ---------- */
function toast(msg) {
const el = document.createElement("div");
el.className = "toast";
el.textContent = msg;
toastHost.appendChild(el);
setTimeout(() => {
el.classList.add("toast-out");
el.addEventListener("animationend", () => el.remove());
}, 2400);
}
/* ---------- Filters ---------- */
function setFilter(type) {
activeFilter = type;
document.querySelectorAll(".chip").forEach((chip) => {
const active = chip.dataset.type === type;
chip.classList.toggle("is-active", active);
chip.setAttribute("aria-pressed", String(active));
});
renderGrid();
}
/* ---------- Events ---------- */
document.getElementById("chips").addEventListener("click", (e) => {
const chip = e.target.closest(".chip");
if (chip) setFilter(chip.dataset.type);
});
grid.addEventListener("click", (e) => {
const block = e.target.closest(".block");
if (!block) return;
const c = classes.find((x) => x.id === block.dataset.id);
if (c) openPanel(c);
});
document.getElementById("prevWeek").addEventListener("click", () => { weekOffset--; renderHeaders(); renderGrid(); });
document.getElementById("nextWeek").addEventListener("click", () => { weekOffset++; renderHeaders(); renderGrid(); });
document.getElementById("todayBtn").addEventListener("click", () => {
weekOffset = 0; renderHeaders(); renderGrid(); toast("Jumped to this week");
});
document.getElementById("panelClose").addEventListener("click", closePanel);
overlay.addEventListener("click", closePanel);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && panel.classList.contains("is-open")) closePanel();
});
/* ---------- Init ---------- */
renderHeaders();
renderGrid();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pulse Athletics — Class Schedule</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">P</span>
<div class="brand-text">
<span class="brand-eyebrow">Pulse Athletics</span>
<h1 class="brand-title">Class Schedule</h1>
</div>
</div>
<nav class="week-nav" aria-label="Week navigation">
<button class="wk-btn" id="prevWeek" type="button" aria-label="Previous week">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="wk-range" id="weekRange">This Week</div>
<button class="wk-btn" id="nextWeek" type="button" aria-label="Next week">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M9 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="wk-today" id="todayBtn" type="button">Today</button>
</nav>
</header>
<section class="filters" aria-label="Filter classes by type">
<span class="filters-label">Filter</span>
<div class="chips" id="chips" role="group">
<button class="chip is-active" data-type="all" type="button" aria-pressed="true">All</button>
<button class="chip" data-type="Strength" type="button" aria-pressed="false"><span class="dot dot-strength"></span>Strength</button>
<button class="chip" data-type="Cardio" type="button" aria-pressed="false"><span class="dot dot-cardio"></span>Cardio</button>
<button class="chip" data-type="Yoga" type="button" aria-pressed="false"><span class="dot dot-yoga"></span>Yoga</button>
<button class="chip" data-type="Cycle" type="button" aria-pressed="false"><span class="dot dot-cycle"></span>Cycle</button>
</div>
<div class="legend" aria-label="Intensity legend">
<span class="leg leg-low">Low</span>
<span class="leg leg-mid">Moderate</span>
<span class="leg leg-high">High</span>
</div>
</section>
<div class="grid-wrap">
<div class="grid" id="grid" role="grid" aria-label="Weekly class schedule">
<div class="corner" role="columnheader">Time</div>
<div class="dayhead" data-day="0" role="columnheader"><span class="d-name">Mon</span><span class="d-date"></span></div>
<div class="dayhead" data-day="1" role="columnheader"><span class="d-name">Tue</span><span class="d-date"></span></div>
<div class="dayhead" data-day="2" role="columnheader"><span class="d-name">Wed</span><span class="d-date"></span></div>
<div class="dayhead" data-day="3" role="columnheader"><span class="d-name">Thu</span><span class="d-date"></span></div>
<div class="dayhead" data-day="4" role="columnheader"><span class="d-name">Fri</span><span class="d-date"></span></div>
<div class="dayhead" data-day="5" role="columnheader"><span class="d-name">Sat</span><span class="d-date"></span></div>
<div class="dayhead" data-day="6" role="columnheader"><span class="d-name">Sun</span><span class="d-date"></span></div>
<!-- rows injected by script.js -->
</div>
</div>
</div>
<!-- Detail panel -->
<div class="panel-overlay" id="overlay" hidden></div>
<aside class="panel" id="panel" aria-hidden="true" aria-label="Class details" tabindex="-1">
<button class="panel-close" id="panelClose" type="button" aria-label="Close details">
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>
</button>
<div class="panel-body" id="panelBody"><!-- injected --></div>
</aside>
<div class="toast-host" id="toastHost" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Class Schedule
A weekly class-schedule grid for a high-energy performance gym. Seven day columns (Mon–Sun) run across fixed time rows, with sticky headers so the days and times stay in view while you scroll the timetable. Each class block is color-coded by type and shows the class name, trainer, start time, intensity tag and a live spots-left counter that turns amber when a class is nearly full and red when it sells out. The current day gets a highlighted column and an accented header.
Filter chips along the top (Strength, Cardio, Yoga, Cycle) dim every block that does not match, making it easy to scan for the kind of session you want. A week navigator with prev / next buttons and a Today shortcut lets you move between weeks, updating the date labels and the “today” highlight accordingly.
Clicking any class opens a slide-in detail panel with the full session info — room, exact time window, capacity bar and a description. The big neon Book button decrements the remaining spots, flips the panel and the grid block to a Booked state, and can be tapped again to cancel. A small toast confirms every action. Everything is vanilla HTML, CSS and JavaScript with no dependencies, keyboard accessible, and responsive down to ~360px.