Clinic — Doctor / Room Schedule Admin
A dashboard-grade scheduling grid for clinic front-desk staff. Rows are providers or treatment rooms via a toggle, columns are 30-minute time blocks from 8 to 5, and appointment blocks span their duration colored by visit type — new, follow-up, procedure or telehealth. Clicking a block slides out a detail panel with patient, time, room and reason, while a Day/Week toggle, doctor filter and live booked-versus-open utilization summary update on every change.
MCP
Código
:root {
--teal: #129c93;
--teal-d: #0c7a73;
--teal-700: #0a655f;
--teal-50: #e7f5f3;
--coral: #ff7a66;
--coral-soft: #ffe6df;
--ink: #16322f;
--ink-2: #3a534f;
--muted: #6b827e;
--bg: #f1f7f6;
--white: #ffffff;
--line: rgba(16, 50, 47, 0.1);
--line-2: rgba(16, 50, 47, 0.18);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--font: "Inter", system-ui, -apple-system, sans-serif;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-1: 0 1px 2px rgba(16, 50, 47, 0.05), 0 4px 14px rgba(16, 50, 47, 0.06);
--shadow-2: 0 16px 40px rgba(12, 122, 115, 0.16);
--shadow-3: -12px 0 40px rgba(16, 50, 47, 0.14);
/* visit type colors */
--new-bg: #e7f5f3;
--new-bd: #0a655f;
--new-fg: #0a655f;
--follow-bg: #eef3ff;
--follow-bd: #3f5fc4;
--follow-fg: #2f49a0;
--proc-bg: #ffe6df;
--proc-bd: #c4503a;
--proc-fg: #b3402d;
--tele-bg: #fff2dd;
--tele-bd: #d98a2b;
--tele-fg: #a96b18;
/* grid metrics */
--row-h: 58px;
--col-w: 116px;
--label-w: 168px;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font);
background: var(--bg);
color: var(--ink);
-webkit-font-smoothing: antialiased;
line-height: 1.5;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
button:focus-visible,
select:focus-visible,
.appt:focus-visible {
outline: 2px solid var(--teal);
outline-offset: 2px;
}
/* ── Layout ── */
.admin {
max-width: 1100px;
margin: 0 auto;
padding: 28px 22px 64px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* ── Header ── */
.admin-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 46px;
height: 46px;
border-radius: var(--r-md);
background: linear-gradient(150deg, var(--teal), var(--teal-700));
color: #fff;
display: grid;
place-items: center;
font-weight: 800;
font-size: 1.3rem;
box-shadow: var(--shadow-1);
}
.brand-text h1 {
font-size: 1.55rem;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.1;
}
.sub {
color: var(--muted);
font-size: 0.88rem;
font-weight: 500;
}
.head-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
/* ── Segmented toggles ── */
.seg {
display: inline-flex;
gap: 3px;
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 3px;
box-shadow: var(--shadow-1);
}
.seg-btn {
border: none;
background: transparent;
border-radius: 999px;
padding: 7px 16px;
font: inherit;
font-weight: 600;
font-size: 0.84rem;
color: var(--muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.seg-btn:hover {
color: var(--ink-2);
}
.seg-btn.is-active {
background: var(--teal-d);
color: #fff;
}
/* ── Select ── */
.field {
display: inline-flex;
}
select {
font: inherit;
font-weight: 600;
font-size: 0.84rem;
color: var(--ink-2);
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 32px 8px 16px;
cursor: pointer;
box-shadow: var(--shadow-1);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b827e' d='M2 4l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
}
select:hover {
border-color: var(--teal);
}
/* ── Stats ── */
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) 1.6fr;
gap: 12px;
}
.stat {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px;
box-shadow: var(--shadow-1);
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-label {
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.stat-value {
font-size: 1.7rem;
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.1;
color: var(--ink);
}
.stat-foot {
font-size: 0.78rem;
color: var(--muted);
font-weight: 500;
}
.stat-util .stat-value {
color: var(--teal-d);
}
.util-bar {
margin-top: 8px;
height: 8px;
border-radius: 999px;
background: var(--teal-50);
overflow: hidden;
}
.util-fill {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--teal), var(--teal-700));
transition: width 0.4s ease;
}
.legend {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px;
box-shadow: var(--shadow-1);
display: flex;
flex-wrap: wrap;
align-content: center;
gap: 10px 18px;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-2);
}
.dot {
width: 11px;
height: 11px;
border-radius: 3px;
flex-shrink: 0;
}
.dot.t-new {
background: var(--new-bd);
}
.dot.t-follow {
background: var(--follow-bd);
}
.dot.t-proc {
background: var(--proc-bd);
}
.dot.t-tele {
background: var(--tele-bd);
}
/* ── Grid ── */
.grid-wrap {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-1);
overflow: hidden;
}
.grid-scroll {
overflow-x: auto;
overflow-y: hidden;
}
.grid {
display: grid;
min-width: max-content;
}
/* corner + headers */
.g-corner,
.g-time,
.g-rowlabel,
.g-cell {
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.g-corner {
position: sticky;
left: 0;
z-index: 4;
background: var(--white);
width: var(--label-w);
display: flex;
align-items: center;
padding: 0 18px;
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.g-time {
background: #f7fbfa;
height: 46px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.78rem;
font-weight: 700;
color: var(--ink-2);
width: var(--col-w);
}
.g-rowlabel {
position: sticky;
left: 0;
z-index: 3;
background: var(--white);
width: var(--label-w);
min-height: var(--row-h);
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
}
.row-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--teal-50);
color: var(--teal-d);
display: grid;
place-items: center;
font-weight: 700;
font-size: 0.84rem;
flex-shrink: 0;
}
.row-meta {
min-width: 0;
}
.row-name {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-spec {
font-size: 0.74rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* slot cells track */
.g-track {
position: relative;
height: var(--row-h);
display: grid;
grid-auto-flow: column;
grid-auto-columns: var(--col-w);
}
.g-cell {
height: var(--row-h);
width: var(--col-w);
background: var(--white);
transition: background 0.12s;
}
.g-cell.is-half {
background: #fbfdfc;
}
.g-cell:hover {
background: var(--teal-50);
}
/* appointment blocks layered over track */
.appt {
position: absolute;
top: 6px;
bottom: 6px;
border-radius: var(--r-sm);
border: none;
border-left: 3px solid;
padding: 6px 9px;
cursor: pointer;
text-align: left;
font: inherit;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 1px;
box-shadow: var(--shadow-1);
transition: transform 0.12s, box-shadow 0.15s, filter 0.15s;
}
.appt:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-2);
filter: saturate(1.05);
z-index: 2;
}
.appt:active {
transform: translateY(0);
}
.appt-name {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.appt-sub {
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.85;
}
.appt.is-active {
box-shadow: 0 0 0 2px var(--teal), var(--shadow-2);
}
.appt.t-new {
background: var(--new-bg);
border-left-color: var(--new-bd);
color: var(--new-fg);
}
.appt.t-follow {
background: var(--follow-bg);
border-left-color: var(--follow-bd);
color: var(--follow-fg);
}
.appt.t-proc {
background: var(--proc-bg);
border-left-color: var(--proc-bd);
color: var(--proc-fg);
}
.appt.t-tele {
background: var(--tele-bg);
border-left-color: var(--tele-bd);
color: var(--tele-fg);
}
.is-dim {
opacity: 0.32;
filter: grayscale(0.4);
}
/* ── Detail panel ── */
.panel {
position: fixed;
top: 0;
right: 0;
height: 100%;
width: 360px;
max-width: 90vw;
background: var(--white);
box-shadow: var(--shadow-3);
z-index: 60;
padding: 22px;
display: flex;
flex-direction: column;
gap: 18px;
transform: translateX(100%);
transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.panel.is-open {
transform: translateX(0);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.panel-tag {
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 5px 12px;
border-radius: 999px;
background: var(--teal-50);
color: var(--teal-d);
}
.panel-tag.t-new {
background: var(--new-bg);
color: var(--new-fg);
}
.panel-tag.t-follow {
background: var(--follow-bg);
color: var(--follow-fg);
}
.panel-tag.t-proc {
background: var(--proc-bg);
color: var(--proc-fg);
}
.panel-tag.t-tele {
background: var(--tele-bg);
color: var(--tele-fg);
}
.icon-btn {
border: 1px solid var(--line-2);
background: var(--white);
width: 32px;
height: 32px;
border-radius: 9px;
cursor: pointer;
color: var(--ink-2);
font-size: 0.9rem;
display: grid;
place-items: center;
transition: background 0.15s, border-color 0.15s;
}
.icon-btn:hover {
background: var(--bg);
border-color: var(--teal);
}
.panel-name {
font-size: 1.3rem;
font-weight: 800;
letter-spacing: -0.02em;
}
.panel-rows {
display: flex;
flex-direction: column;
}
.panel-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--line);
}
.panel-row dt {
font-size: 0.82rem;
font-weight: 600;
color: var(--muted);
flex-shrink: 0;
}
.panel-row dd {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
text-align: right;
}
.panel-actions {
margin-top: auto;
display: flex;
gap: 10px;
}
.btn {
flex: 1;
border: none;
border-radius: 10px;
padding: 11px 16px;
font: inherit;
font-weight: 600;
font-size: 0.86rem;
cursor: pointer;
transition: transform 0.12s, background 0.15s, border-color 0.15s;
}
.btn:active {
transform: translateY(1px);
}
.btn.ghost {
background: var(--white);
border: 1px solid var(--line-2);
color: var(--ink-2);
}
.btn.ghost:hover {
background: var(--teal-50);
border-color: var(--teal);
color: var(--teal-d);
}
.btn.solid {
background: var(--teal-d);
color: #fff;
}
.btn.solid:hover {
background: var(--teal-700);
}
.scrim {
position: fixed;
inset: 0;
background: rgba(16, 50, 47, 0.28);
z-index: 55;
opacity: 0;
transition: opacity 0.28s;
}
.scrim.is-open {
opacity: 1;
}
/* ── Toast ── */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
background: var(--ink);
color: #fff;
padding: 13px 20px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 500;
box-shadow: var(--shadow-2);
z-index: 70;
max-width: 90vw;
}
/* ── Responsive ── */
@media (max-width: 760px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.legend {
grid-column: 1 / -1;
}
}
@media (max-width: 520px) {
.admin {
padding: 20px 14px 56px;
}
.admin-head {
align-items: flex-start;
}
.head-controls {
width: 100%;
}
.field,
select {
width: 100%;
}
.stats {
grid-template-columns: 1fr 1fr;
}
.stat-util,
.legend {
grid-column: 1 / -1;
}
:root {
--col-w: 92px;
--label-w: 132px;
--row-h: 54px;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.panel[hidden] {
display: none;
}// ── Toast ──────────────────────────────────────────────────────────────────
const toastEl = document.getElementById("toast");
function toast(msg) {
toastEl.textContent = msg;
toastEl.hidden = false;
clearTimeout(toast._t);
toast._t = setTimeout(() => (toastEl.hidden = true), 2600);
}
// ── Time model ───────────────────────────────────────────────────────────────
// 8:00 AM → 5:00 PM in 30-min blocks. Slot index 0 = 8:00.
const START_MIN = 8 * 60;
const SLOT_MIN = 30;
const SLOTS = 18; // 8:00 .. 4:30 start blocks (last visible block 4:30–5:00)
function slotToLabel(i) {
const total = START_MIN + i * SLOT_MIN;
let h = Math.floor(total / 60);
const m = total % 60;
const mer = h >= 12 ? "PM" : "AM";
if (h > 12) h -= 12;
if (h === 0) h = 12;
return `${h}:${String(m).padStart(2, "0")} ${mer}`;
}
// ── Data: doctors, rooms, appointments ───────────────────────────────────────
const doctors = [
{ id: "okafor", name: "Dr. Lena Okafor", spec: "Cardiology", initials: "LO" },
{ id: "patel", name: "Dr. Ravi Patel", spec: "Primary care", initials: "RP" },
{ id: "bloom", name: "Dr. Maya Bloom", spec: "Dermatology", initials: "MB" },
{ id: "reyes", name: "Dr. Tomas Reyes", spec: "Orthopedics", initials: "TR" },
{ id: "novak", name: "Dr. Anja Novak", spec: "Pediatrics", initials: "AN" },
];
const rooms = [
{ id: "r201", name: "Room 201", spec: "Exam · Floor 2", initials: "201" },
{ id: "r202", name: "Room 202", spec: "Exam · Floor 2", initials: "202" },
{ id: "r210", name: "Procedure A", spec: "Surgical · Floor 2", initials: "PA" },
{ id: "r305", name: "Room 305", spec: "Exam · Floor 3", initials: "305" },
{ id: "tele", name: "Telehealth", spec: "Remote bay", initials: "TH" },
];
const TYPES = {
new: { label: "New patient", cls: "t-new" },
follow: { label: "Follow-up", cls: "t-follow" },
proc: { label: "Procedure", cls: "t-proc" },
tele: { label: "Telehealth", cls: "t-tele" },
};
// start = slot index, len = number of 30-min blocks
const appts = [
// Okafor
{ doctor: "okafor", room: "r201", start: 0, len: 1, type: "follow", patient: "George Hale", reason: "Hypertension review" },
{ doctor: "okafor", room: "r201", start: 2, len: 2, type: "new", patient: "Priya Anand", reason: "Initial cardiac consult" },
{ doctor: "okafor", room: "tele", start: 6, len: 1, type: "tele", patient: "Marcus Webb", reason: "ECG results call" },
{ doctor: "okafor", room: "r210", start: 10, len: 3, type: "proc", patient: "Dolores Kim", reason: "Stress echocardiogram" },
{ doctor: "okafor", room: "r201", start: 15, len: 1, type: "follow", patient: "Ian Frost", reason: "Medication titration" },
// Patel
{ doctor: "patel", room: "r202", start: 0, len: 2, type: "new", patient: "Sofia Marlow", reason: "New patient intake" },
{ doctor: "patel", room: "r202", start: 3, len: 1, type: "follow", patient: "Henry Dawes", reason: "Lab follow-up" },
{ doctor: "patel", room: "tele", start: 5, len: 1, type: "tele", patient: "Aisha Bello", reason: "Refill check-in" },
{ doctor: "patel", room: "r202", start: 8, len: 2, type: "follow", patient: "Owen Pritchard", reason: "Diabetes management" },
{ doctor: "patel", room: "r202", start: 12, len: 1, type: "new", patient: "Lucia Mendez", reason: "Annual physical" },
{ doctor: "patel", room: "r202", start: 16, len: 2, type: "follow", patient: "Nadia Hart", reason: "Thyroid recheck" },
// Bloom
{ doctor: "bloom", room: "r305", start: 1, len: 1, type: "follow", patient: "Theo Vance", reason: "Eczema follow-up" },
{ doctor: "bloom", room: "r210", start: 3, len: 2, type: "proc", patient: "Renata Cole", reason: "Lesion excision" },
{ doctor: "bloom", room: "r305", start: 7, len: 1, type: "new", patient: "Jonah Reed", reason: "Skin screening" },
{ doctor: "bloom", room: "tele", start: 11, len: 1, type: "tele", patient: "Mira Solis", reason: "Rash photo review" },
{ doctor: "bloom", room: "r305", start: 14, len: 2, type: "follow", patient: "Caleb Nguyen", reason: "Acne treatment review" },
// Reyes
{ doctor: "reyes", room: "r210", start: 0, len: 3, type: "proc", patient: "Della Ortiz", reason: "Knee arthroscopy" },
{ doctor: "reyes", room: "r305", start: 5, len: 1, type: "new", patient: "Sam Whitlock", reason: "Shoulder eval" },
{ doctor: "reyes", room: "r305", start: 8, len: 1, type: "follow", patient: "Bea Lindqvist", reason: "Post-op check" },
{ doctor: "reyes", room: "tele", start: 12, len: 1, type: "tele", patient: "Omar Said", reason: "Imaging review" },
{ doctor: "reyes", room: "r305", start: 15, len: 2, type: "follow", patient: "Grace Yoon", reason: "Physio progress" },
// Novak
{ doctor: "novak", room: "r201", start: 4, len: 1, type: "new", patient: "Eli Brandt (age 6)", reason: "Well-child visit" },
{ doctor: "novak", room: "r201", start: 6, len: 1, type: "follow", patient: "Maya Tran (age 4)", reason: "Asthma follow-up" },
{ doctor: "novak", room: "tele", start: 9, len: 1, type: "tele", patient: "Liam Hayes (age 9)", reason: "Cold symptoms call" },
{ doctor: "novak", room: "r201", start: 11, len: 2, type: "new", patient: "Zoe Park (age 2)", reason: "Vaccination + intake" },
{ doctor: "novak", room: "r201", start: 16, len: 1, type: "follow", patient: "Noah Fields (age 7)", reason: "Growth check" },
];
// ── State ────────────────────────────────────────────────────────────────────
let viewMode = "doctor"; // "doctor" | "room"
let range = "day"; // "day" | "week"
let filter = "all";
const grid = document.getElementById("grid");
// ── Populate doctor filter ───────────────────────────────────────────────────
const docFilter = document.getElementById("doctor-filter");
doctors.forEach((d) => {
const opt = document.createElement("option");
opt.value = d.id;
opt.textContent = d.name;
docFilter.appendChild(opt);
});
// ── Render the grid ──────────────────────────────────────────────────────────
function render() {
const rows = viewMode === "doctor" ? doctors : rooms;
const rowKey = viewMode === "doctor" ? "doctor" : "room";
grid.style.gridTemplateColumns = `var(--label-w) repeat(${SLOTS}, var(--col-w))`;
grid.innerHTML = "";
// header row: corner + time labels
const corner = document.createElement("div");
corner.className = "g-corner";
corner.textContent = viewMode === "doctor" ? "Provider" : "Room";
grid.appendChild(corner);
for (let i = 0; i < SLOTS; i++) {
const t = document.createElement("div");
t.className = "g-time";
// label only on the hour to reduce clutter
t.textContent = i % 2 === 0 ? slotToLabel(i) : "";
grid.appendChild(t);
}
// body rows
rows.forEach((row) => {
const label = document.createElement("div");
label.className = "g-rowlabel";
label.innerHTML =
`<span class="row-avatar">${row.initials}</span>` +
`<span class="row-meta">` +
`<span class="row-name">${escape(row.name)}</span>` +
`<span class="row-spec">${escape(row.spec)}</span>` +
`</span>`;
grid.appendChild(label);
// track spanning all slot columns
const track = document.createElement("div");
track.className = "g-track";
track.style.gridColumn = `2 / span ${SLOTS}`;
for (let i = 0; i < SLOTS; i++) {
const cell = document.createElement("div");
cell.className = "g-cell" + (i % 2 === 1 ? " is-half" : "");
track.appendChild(cell);
}
// appointments for this row
appts
.filter((a) => a[rowKey] === row.id)
.forEach((a) => {
const block = document.createElement("button");
const t = TYPES[a.type];
const dimmed =
filter !== "all" && a.doctor !== filter ? " is-dim" : "";
block.className = `appt ${t.cls}${dimmed}`;
block.style.left = `calc(${a.start} * var(--col-w) + 3px)`;
block.style.width = `calc(${a.len} * var(--col-w) - 6px)`;
block.dataset.id = `${a.doctor}-${a.start}-${a.room}`;
const dctr = doctors.find((d) => d.id === a.doctor);
block.innerHTML =
`<span class="appt-name">${escape(a.patient)}</span>` +
`<span class="appt-sub">${slotToLabel(a.start)} · ${t.label}</span>`;
block.addEventListener("click", () => openDetail(a, dctr, block));
block.setAttribute(
"aria-label",
`${a.patient}, ${t.label}, ${slotToLabel(a.start)}, ${
dctr ? dctr.name : ""
}`
);
track.appendChild(block);
});
grid.appendChild(track);
});
updateStats();
}
function escape(s) {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// ── Utilization summary (live) ───────────────────────────────────────────────
function updateStats() {
// Count against currently visible rows, honoring the doctor filter.
let visible = appts;
if (filter !== "all") visible = appts.filter((a) => a.doctor === filter);
const rows = viewMode === "doctor" ? doctors : rooms;
const rowKey = viewMode === "doctor" ? "doctor" : "room";
const activeRows =
filter === "all"
? rows
: rows.filter((r) =>
visible.some((a) => a[rowKey] === r.id)
);
const capacity = (filter === "all" ? rows.length : activeRows.length || 1) *
SLOTS *
(range === "week" ? 5 : 1);
let booked = visible.reduce((sum, a) => sum + a.len, 0);
if (range === "week") booked = Math.round(booked * 4.6); // illustrative week fill
const open = Math.max(capacity - booked, 0);
const util = capacity ? Math.round((booked / capacity) * 100) : 0;
document.getElementById("stat-booked").textContent = booked;
document.getElementById("stat-open").textContent = open;
document.getElementById("stat-util").textContent = `${util}%`;
document.getElementById("util-fill").style.width = `${Math.min(util, 100)}%`;
const unit = range === "week" ? "this week" : "today";
document.getElementById("stat-booked-foot").textContent = `slots ${unit}`;
document.getElementById("stat-open-foot").textContent = `slots ${unit}`;
}
// ── Detail side panel ────────────────────────────────────────────────────────
const panel = document.getElementById("detail");
const scrim = document.getElementById("scrim");
let activeBlock = null;
function openDetail(a, dctr, block) {
if (activeBlock) activeBlock.classList.remove("is-active");
activeBlock = block;
block.classList.add("is-active");
const t = TYPES[a.type];
const room = rooms.find((r) => r.id === a.room);
const tag = document.getElementById("detail-tag");
tag.textContent = t.label;
tag.className = `panel-tag ${t.cls}`;
document.getElementById("detail-title").textContent = a.patient;
const endLabel = slotToLabel(a.start + a.len);
document.getElementById("detail-time").textContent = `${slotToLabel(
a.start
)} – ${endLabel}`;
document.getElementById("detail-doctor").textContent = dctr
? `${dctr.name} · ${dctr.spec}`
: "—";
document.getElementById("detail-room").textContent = room ? room.name : "—";
document.getElementById("detail-type").textContent = t.label;
document.getElementById("detail-reason").textContent = a.reason;
panel.hidden = false;
panel.setAttribute("aria-hidden", "false");
scrim.hidden = false;
requestAnimationFrame(() => {
panel.classList.add("is-open");
scrim.classList.add("is-open");
});
document.getElementById("detail-close").focus();
}
function closeDetail() {
panel.classList.remove("is-open");
scrim.classList.remove("is-open");
panel.setAttribute("aria-hidden", "true");
if (activeBlock) activeBlock.classList.remove("is-active");
setTimeout(() => {
panel.hidden = true;
scrim.hidden = true;
}, 280);
}
document.getElementById("detail-close").addEventListener("click", closeDetail);
scrim.addEventListener("click", closeDetail);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !panel.hidden) closeDetail();
});
panel.querySelectorAll("[data-panel-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const name = document.getElementById("detail-title").textContent;
if (btn.dataset.panelAction === "checkin") {
toast(`${name} checked in — provider notified.`);
} else {
toast(`Reschedule ${name}: drag to a new open slot.`);
}
closeDetail();
});
});
// ── Toggles + filter ─────────────────────────────────────────────────────────
document.getElementById("view-toggle").addEventListener("click", (e) => {
const btn = e.target.closest(".seg-btn");
if (!btn || btn.dataset.view === viewMode) return;
viewMode = btn.dataset.view;
syncSeg("view-toggle", btn);
// filter only applies to doctor rows; reset it when viewing rooms
if (viewMode === "room") {
filter = "all";
docFilter.value = "all";
}
docFilter.disabled = viewMode === "room";
render();
});
document.getElementById("range-toggle").addEventListener("click", (e) => {
const btn = e.target.closest(".seg-btn");
if (!btn || btn.dataset.range === range) return;
range = btn.dataset.range;
syncSeg("range-toggle", btn);
document.querySelector(".sub").textContent =
range === "week"
? "Northpoint Clinic · Week of June 8–12"
: "Northpoint Clinic · Tuesday, June 9";
updateStats();
toast(range === "week" ? "Showing week utilization." : "Showing day view.");
});
docFilter.addEventListener("change", () => {
filter = docFilter.value;
render();
});
function syncSeg(groupId, active) {
document.querySelectorAll(`#${groupId} .seg-btn`).forEach((b) => {
const on = b === active;
b.classList.toggle("is-active", on);
b.setAttribute("aria-pressed", String(on));
});
}
// ── Init ─────────────────────────────────────────────────────────────────────
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Schedule Admin · Northpoint Clinic</title>
</head>
<body>
<main class="admin">
<header class="admin-head">
<div class="brand">
<div class="brand-mark" aria-hidden="true">N</div>
<div class="brand-text">
<h1>Schedule</h1>
<p class="sub">Northpoint Clinic · Tuesday, June 9</p>
</div>
</div>
<div class="head-controls">
<div
class="seg"
role="group"
aria-label="Schedule by"
id="view-toggle"
>
<button class="seg-btn is-active" data-view="doctor" aria-pressed="true">
Doctors
</button>
<button class="seg-btn" data-view="room" aria-pressed="false">
Rooms
</button>
</div>
<div class="seg" role="group" aria-label="Range" id="range-toggle">
<button class="seg-btn is-active" data-range="day" aria-pressed="true">
Day
</button>
<button class="seg-btn" data-range="week" aria-pressed="false">
Week
</button>
</div>
<label class="field">
<span class="sr-only">Filter by doctor</span>
<select id="doctor-filter" aria-label="Filter by doctor">
<option value="all">All doctors</option>
</select>
</label>
</div>
</header>
<!-- ── Utilization summary ── -->
<section class="stats" aria-label="Utilization summary">
<div class="stat">
<span class="stat-label">Booked</span>
<strong class="stat-value" id="stat-booked">0</strong>
<span class="stat-foot" id="stat-booked-foot">slots</span>
</div>
<div class="stat">
<span class="stat-label">Open</span>
<strong class="stat-value" id="stat-open">0</strong>
<span class="stat-foot" id="stat-open-foot">slots</span>
</div>
<div class="stat stat-util">
<span class="stat-label">Utilization</span>
<strong class="stat-value" id="stat-util">0%</strong>
<div class="util-bar" aria-hidden="true">
<span class="util-fill" id="util-fill" style="width: 0%"></span>
</div>
</div>
<div class="legend" aria-label="Visit types">
<span class="legend-item"><i class="dot t-new"></i>New</span>
<span class="legend-item"><i class="dot t-follow"></i>Follow-up</span>
<span class="legend-item"><i class="dot t-proc"></i>Procedure</span>
<span class="legend-item"><i class="dot t-tele"></i>Telehealth</span>
</div>
</section>
<!-- ── Scheduling grid ── -->
<section class="grid-wrap" aria-label="Appointment grid">
<div class="grid-scroll">
<div class="grid" id="grid" role="grid"></div>
</div>
</section>
</main>
<!-- ── Detail side panel ── -->
<aside
class="panel"
id="detail"
aria-labelledby="detail-title"
aria-hidden="true"
hidden
>
<div class="panel-head">
<span class="panel-tag" id="detail-tag">New patient</span>
<button class="icon-btn" id="detail-close" aria-label="Close details">
✕
</button>
</div>
<h2 id="detail-title" class="panel-name">—</h2>
<dl class="panel-rows">
<div class="panel-row">
<dt>Time</dt>
<dd id="detail-time">—</dd>
</div>
<div class="panel-row">
<dt>Provider</dt>
<dd id="detail-doctor">—</dd>
</div>
<div class="panel-row">
<dt>Room</dt>
<dd id="detail-room">—</dd>
</div>
<div class="panel-row">
<dt>Visit type</dt>
<dd id="detail-type">—</dd>
</div>
<div class="panel-row">
<dt>Reason</dt>
<dd id="detail-reason">—</dd>
</div>
</dl>
<div class="panel-actions">
<button class="btn ghost" data-panel-action="reschedule">Reschedule</button>
<button class="btn solid" data-panel-action="checkin">Check in</button>
</div>
</aside>
<div class="scrim" id="scrim" hidden></div>
<div class="toast" id="toast" hidden role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Doctor / Room Schedule Admin
An admin scheduling board for Northpoint Clinic’s front desk. Rows list either providers or treatment rooms — flipped with a segmented Doctors / Rooms toggle — while columns lay out the clinic day in 30-minute blocks from 8:00 AM to 5:00 PM. Each appointment renders as a colored block that spans its true duration and is keyed by visit type: teal for new patients, indigo for follow-ups, coral for procedures and amber for telehealth. A legend and a sticky provider column keep the board readable as it scrolls.
Selecting any block slides out a detail panel with the patient, the time range, the assigned provider and room, the visit type and the reason for the visit, plus quick Reschedule and Check in actions that confirm with a toast. A Day / Week range toggle and a per-doctor filter narrow the board, dimming everything outside the selection. Above the grid, a live utilization summary recomputes booked slots, open slots and a percentage bar every time the view, range or filter changes.
The grid scrolls horizontally on small screens with the provider column pinned in place, and the whole screen reflows cleanly down to about 360px. Everything is vanilla JS — the rows, time axis and appointment blocks are generated from a small data model, so the same markup drives both the doctor and room views.
Illustrative UI only — not intended for real medical use.