LMS — Gradebook
An instructor gradebook for an online course platform, built as a students-by-assignments matrix. Color-coded score cells show grades, pending submissions, and missing work at a glance, with a sticky student column and per-assignment class averages in the header. A cohort filter and search narrow the roster, and clicking any cell opens a grading drawer with the submission, a weighted rubric, a feedback box, and a grade input that writes straight back into the matrix and recomputes averages live.
MCP
Code
:root {
--brand: #5b5bd6;
--brand-d: #4444c2;
--brand-50: #eeeefc;
--accent: #13b981;
--amber: #f59e0b;
--ink: #1a1a2e;
--ink-2: #44465f;
--muted: #6b6e87;
--bg: #f7f7fb;
--surface: #ffffff;
--line: rgba(26, 26, 46, 0.1);
--ok: #13b981;
--danger: #e05656;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-sm: 0 1px 2px rgba(26, 26, 46, 0.06), 0 1px 3px rgba(26, 26, 46, 0.05);
--shadow-md: 0 6px 20px rgba(26, 26, 46, 0.1);
--shadow-lg: 0 18px 50px rgba(26, 26, 46, 0.22);
/* cell tones */
--ok-bg: #e4f7ef;
--ok-ink: #0c7a55;
--mid-bg: #fdf1da;
--mid-ink: #9a6406;
--low-bg: #fce3e3;
--low-ink: #b13b3b;
--pending-bg: #eef0f6;
--pending-ink: #5b5f78;
--missing-bg: #f7e9e9;
--missing-ink: #c05858;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, p { margin: 0; }
/* ---------- Top bar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
align-items: center;
gap: 18px;
padding: 12px 22px;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand__mark {
width: 38px; height: 38px;
border-radius: 11px;
display: grid; place-items: center;
background: linear-gradient(140deg, var(--brand), var(--brand-d));
color: #fff; font-weight: 800; font-size: 18px;
box-shadow: var(--shadow-sm);
}
.brand__text { display: flex; flex-direction: column; line-height: 1.15; }
.brand__text strong { font-size: 15px; font-weight: 700; }
.brand__text span { font-size: 12px; color: var(--muted); }
.course-pill {
margin-left: 6px;
display: flex; align-items: center; gap: 9px;
font-size: 13px; font-weight: 600; color: var(--ink-2);
background: var(--brand-50);
border: 1px solid rgba(91, 91, 214, 0.18);
padding: 7px 12px;
border-radius: 999px;
white-space: nowrap;
}
.course-pill__dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--brand);
}
.level-pill {
font-size: 11px; font-weight: 700;
color: var(--brand-d);
background: #fff;
border: 1px solid rgba(91, 91, 214, 0.25);
padding: 2px 8px; border-radius: 999px;
}
.teacher { margin-left: auto; display: flex; align-items: center; gap: 9px; }
.teacher__avatar {
width: 34px; height: 34px; border-radius: 50%;
display: grid; place-items: center;
background: linear-gradient(140deg, #f7b733, var(--amber));
color: #fff; font-weight: 700; font-size: 12px;
}
.teacher__name { font-size: 13px; font-weight: 600; }
/* ---------- Main ---------- */
.main { padding: 24px 22px 60px; max-width: 1200px; margin: 0 auto; }
.head-row {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 18px; flex-wrap: wrap; margin-bottom: 18px;
}
h1 { font-size: 26px; font-weight: 800; letter-spacing: -0.02em; }
.sub { color: var(--muted); font-size: 13.5px; margin-top: 4px; }
.class-avg {
display: flex; flex-direction: column; align-items: flex-end;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 10px 18px;
box-shadow: var(--shadow-sm);
min-width: 150px;
}
.class-avg__label { font-size: 11.5px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
.class-avg__value { font-size: 28px; font-weight: 800; color: var(--brand-d); line-height: 1.1; }
/* ---------- Toolbar ---------- */
.toolbar {
display: flex; align-items: flex-end; gap: 14px; flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px 16px;
box-shadow: var(--shadow-sm);
margin-bottom: 16px;
}
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 11.5px; font-weight: 600; color: var(--muted); }
.field--search { flex: 1; min-width: 180px; }
select, input[type="search"], input[type="number"], textarea {
font: inherit;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 9px 11px;
}
select { min-width: 180px; cursor: pointer; }
input[type="search"] { width: 100%; }
select:focus-visible, input:focus-visible, textarea:focus-visible {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(91, 91, 214, 0.18);
}
.stats {
margin-left: auto;
display: flex; gap: 18px; align-items: center;
font-size: 13px; color: var(--ink-2);
}
.stats b { color: var(--ink); font-weight: 700; }
.stats .stat { display: flex; flex-direction: column; line-height: 1.2; }
.stats .stat span { font-size: 11px; color: var(--muted); }
/* ---------- Legend ---------- */
.legend {
display: flex; gap: 16px; flex-wrap: wrap;
margin: 0 2px 14px;
font-size: 12.5px; color: var(--ink-2);
}
.legend__item { display: inline-flex; align-items: center; gap: 7px; }
.chip { width: 13px; height: 13px; border-radius: 4px; display: inline-block; }
.chip--ok { background: var(--ok-bg); border: 1px solid var(--ok); }
.chip--mid { background: var(--mid-bg); border: 1px solid var(--amber); }
.chip--low { background: var(--low-bg); border: 1px solid var(--danger); }
.chip--pending { background: var(--pending-bg); border: 1px solid #aab; }
.chip--missing { background: var(--missing-bg); border: 1px dashed var(--danger); }
/* ---------- Matrix ---------- */
.matrix-wrap {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-sm);
overflow: auto;
max-width: 100%;
}
.matrix {
border-collapse: separate;
border-spacing: 0;
width: 100%;
min-width: 760px;
}
.matrix th, .matrix td {
padding: 0;
text-align: center;
border-bottom: 1px solid var(--line);
}
.matrix thead th {
position: sticky; top: 0; z-index: 5;
background: #fbfbfe;
padding: 12px 10px;
font-size: 12px; font-weight: 700; color: var(--ink-2);
vertical-align: bottom;
}
.col-head { display: flex; flex-direction: column; gap: 4px; align-items: center; min-width: 96px; }
.col-head__title { font-weight: 700; }
.col-head__meta { display: flex; gap: 6px; align-items: center; }
.weight-pill {
font-size: 10px; font-weight: 700; color: var(--brand-d);
background: var(--brand-50); padding: 1px 6px; border-radius: 999px;
}
.col-avg {
font-size: 11px; color: var(--muted); font-weight: 600;
}
.col-avg b { color: var(--ink); }
/* sticky student column */
.stu-head, .stu-cell {
position: sticky; left: 0; z-index: 6;
text-align: left;
background: var(--surface);
border-right: 1px solid var(--line);
min-width: 210px;
}
.matrix thead .stu-head { z-index: 8; background: #fbfbfe; }
.stu-cell { padding: 10px 14px; }
.stu {
display: flex; align-items: center; gap: 10px;
}
.stu__avatar {
width: 34px; height: 34px; border-radius: 50%;
display: grid; place-items: center; flex: none;
color: #fff; font-weight: 700; font-size: 12px;
}
.stu__info { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
.stu__name { font-size: 13.5px; font-weight: 600; }
.stu__meta { font-size: 11.5px; color: var(--muted); }
.cohort-tag {
display: inline-block;
font-size: 10px; font-weight: 700;
padding: 1px 6px; border-radius: 999px;
background: #eef0f6; color: var(--ink-2);
margin-left: 6px;
}
tbody tr:hover .stu-cell { background: #fafaff; }
tbody tr:last-child td { border-bottom: none; }
/* score cells */
.cell {
width: 100%;
min-height: 52px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 2px;
font: inherit;
border: none;
background: transparent;
cursor: pointer;
padding: 10px 8px;
transition: transform 0.08s ease, box-shadow 0.12s ease;
}
.cell__score { font-size: 15px; font-weight: 700; }
.cell__tag { font-size: 10.5px; font-weight: 600; opacity: 0.85; }
.cell:hover { transform: translateY(-1px); }
.cell:focus-visible {
outline: none;
box-shadow: inset 0 0 0 2px var(--brand);
border-radius: 6px;
}
td.score-cell { padding: 4px; }
.cell--ok { background: var(--ok-bg); color: var(--ok-ink); border-radius: 8px; }
.cell--mid { background: var(--mid-bg); color: var(--mid-ink); border-radius: 8px; }
.cell--low { background: var(--low-bg); color: var(--low-ink); border-radius: 8px; }
.cell--pending { background: var(--pending-bg); color: var(--pending-ink); border-radius: 8px; }
.cell--missing { background: var(--missing-bg); color: var(--missing-ink); border-radius: 8px; border: 1px dashed rgba(224,86,86,0.5); }
.cell--flash { animation: flash 0.6s ease; }
@keyframes flash {
0% { box-shadow: inset 0 0 0 3px var(--brand); }
100% { box-shadow: inset 0 0 0 0 transparent; }
}
.empty { padding: 40px; text-align: center; color: var(--muted); font-size: 14px; }
/* ---------- Drawer ---------- */
.scrim {
position: fixed; inset: 0;
background: rgba(26, 26, 46, 0.42);
backdrop-filter: blur(2px);
z-index: 40;
animation: fade 0.18s ease;
}
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0;
width: min(440px, 94vw);
background: var(--surface);
z-index: 50;
display: flex; flex-direction: column;
box-shadow: var(--shadow-lg);
transform: translateX(102%);
transition: transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.drawer.open { transform: translateX(0); }
.drawer__head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--line);
}
.drawer__eyebrow { font-size: 11.5px; font-weight: 700; color: var(--brand-d); text-transform: uppercase; letter-spacing: 0.05em; }
#drawerTitle { font-size: 18px; font-weight: 700; margin-top: 3px; }
.icon-btn {
border: 1px solid var(--line); background: var(--surface);
width: 34px; height: 34px; border-radius: 9px;
font-size: 22px; line-height: 1; color: var(--muted);
cursor: pointer; flex: none;
}
.icon-btn:hover { background: var(--bg); color: var(--ink); }
.drawer__body { padding: 18px 20px; overflow-y: auto; flex: 1; }
.drawer__h3 { font-size: 13px; font-weight: 700; color: var(--ink-2); text-transform: uppercase; letter-spacing: 0.04em; margin: 18px 0 10px; }
.submission {
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px;
font-size: 13px;
}
.submission__row { display: flex; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
.submission__row span { color: var(--muted); }
.submission__row b { font-weight: 600; }
.submission__note {
margin-top: 10px; padding-top: 10px;
border-top: 1px solid var(--line);
color: var(--ink-2); line-height: 1.55;
}
.status-pill {
font-size: 11px; font-weight: 700;
padding: 2px 9px; border-radius: 999px;
}
.status-pill--submitted { background: var(--brand-50); color: var(--brand-d); }
.status-pill--graded { background: var(--ok-bg); color: var(--ok-ink); }
.status-pill--missing { background: var(--missing-bg); color: var(--missing-ink); }
.rubric { display: flex; flex-direction: column; gap: 10px; }
.rubric__item {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 11px 13px;
}
.rubric__top { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.rubric__name { font-size: 13.5px; font-weight: 600; }
.rubric__max { font-size: 11.5px; color: var(--muted); }
.rubric__desc { font-size: 12px; color: var(--muted); margin: 3px 0 9px; }
.rubric__pts { display: flex; gap: 6px; flex-wrap: wrap; }
.pt-btn {
font: inherit; font-size: 12.5px; font-weight: 600;
min-width: 34px;
border: 1px solid var(--line); background: var(--surface);
color: var(--ink-2);
padding: 5px 9px; border-radius: 8px; cursor: pointer;
transition: all 0.12s ease;
}
.pt-btn:hover { border-color: var(--brand); color: var(--brand-d); }
.pt-btn.active {
background: var(--brand); border-color: var(--brand); color: #fff;
}
.rubric-total {
display: flex; justify-content: space-between; align-items: center;
margin: 14px 0 6px;
font-size: 13px; color: var(--ink-2);
background: var(--brand-50); border-radius: var(--r-sm);
padding: 9px 13px;
}
.rubric-total strong { font-size: 15px; color: var(--brand-d); }
.drawer__label { display: block; font-size: 12px; font-weight: 600; color: var(--ink-2); margin: 16px 0 6px; }
textarea { width: 100%; resize: vertical; line-height: 1.5; }
.grade-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
.grade-row > label { font-size: 13px; font-weight: 700; }
.grade-input { display: flex; align-items: center; gap: 6px; }
.grade-input input { width: 78px; font-size: 16px; font-weight: 700; text-align: center; }
.grade-input span { color: var(--muted); font-size: 13px; }
.drawer__foot {
display: flex; gap: 10px; justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid var(--line);
background: #fbfbfe;
}
.primary-btn {
font: inherit; font-weight: 700; font-size: 14px;
color: #fff;
background: linear-gradient(140deg, var(--brand), var(--brand-d));
border: none; border-radius: var(--r-sm);
padding: 11px 20px; cursor: pointer;
box-shadow: var(--shadow-sm);
transition: filter 0.12s ease, transform 0.08s ease;
}
.primary-btn:hover { filter: brightness(1.05); }
.primary-btn:active { transform: translateY(1px); }
.ghost-btn {
font: inherit; font-weight: 600; font-size: 13px;
color: var(--ink-2);
background: var(--surface);
border: 1px solid var(--line); border-radius: var(--r-sm);
padding: 10px 14px; cursor: pointer;
transition: all 0.12s ease;
}
.ghost-btn:hover { border-color: var(--brand); color: var(--brand-d); }
/* ---------- Toast ---------- */
.toast {
position: fixed; left: 50%; bottom: 26px;
transform: translate(-50%, 18px);
background: var(--ink); color: #fff;
font-size: 13.5px; font-weight: 600;
padding: 11px 18px; border-radius: 999px;
box-shadow: var(--shadow-md);
opacity: 0; pointer-events: none;
z-index: 60;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 720px) {
.topbar { flex-wrap: wrap; gap: 10px; }
.course-pill { order: 3; width: 100%; justify-content: flex-start; }
.teacher { margin-left: auto; }
.stats { margin-left: 0; width: 100%; justify-content: space-between; }
}
@media (max-width: 520px) {
.main { padding: 18px 14px 50px; }
h1 { font-size: 22px; }
.toolbar { padding: 12px; }
.field, .field--search { width: 100%; }
select { min-width: 0; width: 100%; }
.class-avg { min-width: 0; align-items: flex-start; }
.stu-head, .stu-cell { min-width: 150px; }
.stu__avatar { width: 30px; height: 30px; }
.drawer { width: 100vw; }
}"use strict";
/* ---------------- Data (fictional) ---------------- */
const ASSIGNMENTS = [
{ id: "a1", title: "HTML Basics", short: "HTML", weight: 10 },
{ id: "a2", title: "Flexbox Lab", short: "Flexbox", weight: 15 },
{ id: "a3", title: "JS Functions", short: "JS Fns", weight: 20 },
{ id: "a4", title: "Fetch & APIs", short: "APIs", weight: 25 },
{ id: "a5", title: "Capstone App", short: "Capstone", weight: 30 },
];
const RUBRIC = [
{ id: "r1", name: "Correctness", desc: "Meets the spec and runs without errors.", max: 4 },
{ id: "r2", name: "Code quality", desc: "Readable, well-named, no dead code.", max: 3 },
{ id: "r3", name: "Accessibility", desc: "Semantic markup, labels, keyboard usable.", max: 2 },
{ id: "r4", name: "Polish", desc: "Visual care and edge-case handling.", max: 1 },
];
const RUBRIC_MAX = RUBRIC.reduce((s, r) => s + r.max, 0); // 10
const AVATAR_TONES = [
"linear-gradient(140deg,#5b5bd6,#4444c2)",
"linear-gradient(140deg,#13b981,#0c9a6f)",
"linear-gradient(140deg,#f59e0b,#e07a16)",
"linear-gradient(140deg,#e05656,#c23b6e)",
"linear-gradient(140deg,#3aa0ff,#5b5bd6)",
"linear-gradient(140deg,#9b5bd6,#6b3bc2)",
];
// score: number 0-100 | "pending" (submitted, ungraded) | null (missing)
const STUDENTS = [
{ id: "s1", name: "Mara Velez", email: "[email protected]", cohort: "A", scores: { a1: 92, a2: 88, a3: 95, a4: 81, a5: "pending" } },
{ id: "s2", name: "Theo Okonkwo", email: "[email protected]", cohort: "A", scores: { a1: 78, a2: 71, a3: 64, a4: "pending", a5: null } },
{ id: "s3", name: "Lena Hoffmann", email: "[email protected]", cohort: "B", scores: { a1: 100, a2: 96, a3: 91, a4: 94, a5: 89 } },
{ id: "s4", name: "Darius Cole", email: "[email protected]", cohort: "B", scores: { a1: 58, a2: 62, a3: 55, a4: null, a5: null } },
{ id: "s5", name: "Aiko Tanaka", email: "[email protected]", cohort: "C", scores: { a1: 84, a2: 79, a3: 87, a4: 90, a5: "pending" } },
{ id: "s6", name: "Samir Haddad", email: "[email protected]", cohort: "C", scores: { a1: 67, a2: 73, a3: "pending", a4: null, a5: null } },
{ id: "s7", name: "Priya Nair", email: "[email protected]", cohort: "A", scores: { a1: 95, a2: 90, a3: 98, a4: 92, a5: 96 } },
{ id: "s8", name: "Gabriel Reyes", email: "[email protected]", cohort: "B", scores: { a1: 49, a2: 58, a3: 61, a4: "pending", a5: null } },
{ id: "s9", name: "Noor Farah", email: "[email protected]", cohort: "C", scores: { a1: 88, a2: 85, a3: 80, a4: 83, a5: "pending" } },
];
const SUBMISSION_NOTES = {
pending: "Submitted on time. Includes a live demo link and a short write-up. Awaiting your review.",
graded: "Graded. Feedback was shared with the student.",
missing: "No submission received. The deadline has passed.",
};
/* ---------------- State ---------------- */
let cohortFilter = "all";
let searchTerm = "";
const drawerState = { studentId: null, assignmentId: null, rubricScores: {} };
/* ---------------- Helpers ---------------- */
const $ = (sel, root = document) => root.querySelector(sel);
const initials = (name) => name.split(" ").map((p) => p[0]).slice(0, 2).join("").toUpperCase();
const toneFor = (id) => AVATAR_TONES[(id.charCodeAt(1) || 0) % AVATAR_TONES.length];
function band(score) {
if (score === null) return "missing";
if (score === "pending") return "pending";
if (score >= 85) return "ok";
if (score >= 60) return "mid";
return "low";
}
let toastTimer;
function toast(msg) {
const el = $("#toast");
el.textContent = msg;
el.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("show"), 2200);
}
function filteredStudents() {
return STUDENTS.filter((s) => {
if (cohortFilter !== "all" && s.cohort !== cohortFilter) return false;
if (searchTerm && !s.name.toLowerCase().includes(searchTerm)) return false;
return true;
});
}
function assignmentAverage(assignmentId, students) {
const vals = students
.map((s) => s.scores[assignmentId])
.filter((v) => typeof v === "number");
if (!vals.length) return null;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
function classAverage(students) {
const vals = [];
students.forEach((s) =>
ASSIGNMENTS.forEach((a) => {
const v = s.scores[a.id];
if (typeof v === "number") vals.push(v);
})
);
if (!vals.length) return null;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length);
}
/* ---------------- Render ---------------- */
function renderHead(students) {
const head = $("#matrixHead");
let cols = ASSIGNMENTS.map((a) => {
const avg = assignmentAverage(a.id, students);
return `<th scope="col">
<div class="col-head">
<span class="col-head__title">${a.short}</span>
<span class="col-head__meta"><span class="weight-pill">${a.weight}%</span></span>
<span class="col-avg" data-avg="${a.id}">avg <b>${avg === null ? "—" : avg}</b></span>
</div>
</th>`;
}).join("");
head.innerHTML = `<tr>
<th scope="col" class="stu-head">Student</th>
${cols}
</tr>`;
}
function cellMarkup(student, assignment) {
const v = student.scores[assignment.id];
const b = band(v);
let score = "", tag = "";
if (v === null) { score = "—"; tag = "Missing"; }
else if (v === "pending") { score = "•••"; tag = "To grade"; }
else { score = v; tag = ""; }
return `<td class="score-cell">
<button class="cell cell--${b}" data-student="${student.id}" data-assignment="${assignment.id}"
aria-label="${student.name}, ${assignment.title}: ${v === null ? "missing" : v === "pending" ? "awaiting grade" : v}. Open to grade.">
<span class="cell__score">${score}</span>
${tag ? `<span class="cell__tag">${tag}</span>` : ""}
</button>
</td>`;
}
function renderBody(students) {
const body = $("#matrixBody");
const empty = $("#emptyState");
if (!students.length) {
body.innerHTML = "";
empty.hidden = false;
return;
}
empty.hidden = true;
body.innerHTML = students.map((s) => {
const cells = ASSIGNMENTS.map((a) => cellMarkup(s, a)).join("");
return `<tr>
<td class="stu-cell">
<div class="stu">
<span class="stu__avatar" style="background:${toneFor(s.id)}" aria-hidden="true">${initials(s.name)}</span>
<span class="stu__info">
<span class="stu__name">${s.name}<span class="cohort-tag">${s.cohort}</span></span>
<span class="stu__meta">${s.email}</span>
</span>
</div>
</td>
${cells}
</tr>`;
}).join("");
}
function renderStats(students) {
let graded = 0, pending = 0, missing = 0;
students.forEach((s) => ASSIGNMENTS.forEach((a) => {
const v = s.scores[a.id];
if (v === null) missing++;
else if (v === "pending") pending++;
else graded++;
}));
$("#stats").innerHTML = `
<span class="stat"><b>${students.length}</b><span>students</span></span>
<span class="stat"><b>${graded}</b><span>graded</span></span>
<span class="stat"><b>${pending}</b><span>to grade</span></span>
<span class="stat"><b>${missing}</b><span>missing</span></span>`;
}
function renderClassAvg(students) {
const avg = classAverage(students);
$("#classAvg").textContent = avg === null ? "—" : avg + "%";
}
function renderAll() {
const students = filteredStudents();
renderHead(students);
renderBody(students);
renderStats(students);
renderClassAvg(students);
}
/* Update only the averages without rebuilding the whole table */
function refreshAverages() {
const students = filteredStudents();
ASSIGNMENTS.forEach((a) => {
const el = $(`.col-avg[data-avg="${a.id}"] b`);
if (el) {
const avg = assignmentAverage(a.id, students);
el.textContent = avg === null ? "—" : avg;
}
});
renderStats(students);
renderClassAvg(students);
}
/* ---------------- Drawer ---------------- */
function statusOf(v) {
if (v === null) return "missing";
if (v === "pending") return "submitted";
return "graded";
}
function openDrawer(studentId, assignmentId) {
const student = STUDENTS.find((s) => s.id === studentId);
const assignment = ASSIGNMENTS.find((a) => a.id === assignmentId);
if (!student || !assignment) return;
drawerState.studentId = studentId;
drawerState.assignmentId = assignmentId;
drawerState.rubricScores = {};
const v = student.scores[assignmentId];
const status = statusOf(v);
$("#drawerEyebrow").textContent = `${assignment.title} · ${assignment.weight}% of grade`;
$("#drawerTitle").textContent = student.name;
const submittedDates = { pending: "Apr 18, 11:42 PM", graded: "Apr 12, 9:05 PM", missing: "—" };
$("#submission").innerHTML = `
<div class="submission__row"><span>Cohort</span><b>${student.cohort} · ${student.email}</b></div>
<div class="submission__row"><span>Submitted</span><b>${submittedDates[status] || "—"}</b></div>
<div class="submission__row"><span>Status</span>
<span class="status-pill status-pill--${status}">${status}</span></div>
<p class="submission__note">${SUBMISSION_NOTES[status === "submitted" ? "pending" : status]}</p>`;
renderRubric();
const fb = $("#feedback");
fb.value = status === "graded"
? "Solid work overall — strong logic. Tighten variable names next time."
: "";
const gradeInput = $("#gradeInput");
gradeInput.value = typeof v === "number" ? v : "";
$("#scrim").hidden = false;
const drawer = $("#drawer");
drawer.setAttribute("aria-hidden", "false");
requestAnimationFrame(() => drawer.classList.add("open"));
$("#closeDrawer").focus();
}
function renderRubric() {
const wrap = $("#rubric");
wrap.innerHTML = RUBRIC.map((r) => {
const pts = Array.from({ length: r.max + 1 }, (_, i) => i)
.map((p) => `<button type="button" class="pt-btn" data-rubric="${r.id}" data-pts="${p}">${p}</button>`)
.join("");
return `<div class="rubric__item">
<div class="rubric__top">
<span class="rubric__name">${r.name}</span>
<span class="rubric__max">/ ${r.max} pts</span>
</div>
<p class="rubric__desc">${r.desc}</p>
<div class="rubric__pts">${pts}</div>
</div>`;
}).join("");
updateRubricTotal();
}
function updateRubricTotal() {
const total = Object.values(drawerState.rubricScores).reduce((a, b) => a + b, 0);
$("#rubricTotal").textContent = `${total} / ${RUBRIC_MAX}`;
}
function closeDrawer() {
const drawer = $("#drawer");
drawer.classList.remove("open");
drawer.setAttribute("aria-hidden", "true");
setTimeout(() => { $("#scrim").hidden = true; }, 240);
}
/* Apply a new score into the data + the matrix cell */
function applyScore(studentId, assignmentId, newScore) {
const student = STUDENTS.find((s) => s.id === studentId);
if (!student) return;
student.scores[assignmentId] = newScore;
const cell = $(`.cell[data-student="${studentId}"][data-assignment="${assignmentId}"]`);
if (cell) {
const td = cell.closest("td");
const fresh = document.createElement("template");
fresh.innerHTML = cellMarkup(student, ASSIGNMENTS.find((a) => a.id === assignmentId)).trim();
const newTd = fresh.content.firstChild;
td.replaceWith(newTd);
const newCell = newTd.querySelector(".cell");
newCell.classList.add("cell--flash");
setTimeout(() => newCell.classList.remove("cell--flash"), 600);
}
refreshAverages();
}
/* ---------------- Events ---------------- */
function init() {
renderAll();
$("#cohort").addEventListener("change", (e) => {
cohortFilter = e.target.value;
renderAll();
});
$("#search").addEventListener("input", (e) => {
searchTerm = e.target.value.trim().toLowerCase();
renderAll();
});
// open drawer from any cell
$("#matrix").addEventListener("click", (e) => {
const cell = e.target.closest(".cell");
if (cell) openDrawer(cell.dataset.student, cell.dataset.assignment);
});
$("#closeDrawer").addEventListener("click", closeDrawer);
$("#scrim").addEventListener("click", closeDrawer);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("#scrim").hidden) closeDrawer();
});
// rubric point buttons (delegated)
$("#rubric").addEventListener("click", (e) => {
const btn = e.target.closest(".pt-btn");
if (!btn) return;
const rid = btn.dataset.rubric;
const pts = Number(btn.dataset.pts);
drawerState.rubricScores[rid] = pts;
btn.parentElement.querySelectorAll(".pt-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
updateRubricTotal();
});
// use rubric total as a suggested grade (scaled to 100)
$("#useRubric").addEventListener("click", () => {
const total = Object.values(drawerState.rubricScores).reduce((a, b) => a + b, 0);
if (!total) { toast("Score the rubric first"); return; }
const grade = Math.round((total / RUBRIC_MAX) * 100);
$("#gradeInput").value = grade;
toast(`Suggested grade: ${grade}`);
});
// save grade
$("#saveGrade").addEventListener("click", () => {
const raw = $("#gradeInput").value;
const grade = Number(raw);
if (raw === "" || Number.isNaN(grade) || grade < 0 || grade > 100) {
toast("Enter a grade from 0 to 100");
$("#gradeInput").focus();
return;
}
applyScore(drawerState.studentId, drawerState.assignmentId, Math.round(grade));
const name = STUDENTS.find((s) => s.id === drawerState.studentId).name.split(" ")[0];
closeDrawer();
toast(`Saved ${Math.round(grade)} for ${name}`);
});
// mark missing
$("#markMissing").addEventListener("click", () => {
applyScore(drawerState.studentId, drawerState.assignmentId, null);
closeDrawer();
toast("Marked as missing");
});
}
document.addEventListener("DOMContentLoaded", init);<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gradebook — Northbridge Online</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&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">N</span>
<div class="brand__text">
<strong>Northbridge Online</strong>
<span>Gradebook</span>
</div>
</div>
<div class="course-pill">
<span class="course-pill__dot" aria-hidden="true"></span>
CS 201 · Foundations of Web Engineering
<span class="level-pill">Intermediate</span>
</div>
<div class="teacher">
<span class="teacher__avatar" aria-hidden="true">DR</span>
<span class="teacher__name">Dr. Priya Raman</span>
</div>
</header>
<main class="main" aria-labelledby="gb-title">
<div class="head-row">
<div>
<h1 id="gb-title">Gradebook</h1>
<p class="sub">Scores update live as you grade. Click any cell to open the submission.</p>
</div>
<div class="class-avg" aria-live="polite">
<span class="class-avg__label">Class average</span>
<span class="class-avg__value" id="classAvg">—</span>
</div>
</div>
<section class="toolbar" aria-label="Filters">
<div class="field">
<label for="cohort">Cohort</label>
<select id="cohort">
<option value="all">All cohorts</option>
<option value="A">Cohort A · Mornings</option>
<option value="B">Cohort B · Evenings</option>
<option value="C">Cohort C · Remote</option>
</select>
</div>
<div class="field field--search">
<label for="search">Search student</label>
<input id="search" type="search" placeholder="Type a name…" autocomplete="off" />
</div>
<div class="stats" id="stats" aria-live="polite"></div>
</section>
<section class="legend" aria-label="Cell legend">
<span class="legend__item"><i class="chip chip--ok"></i> 85–100</span>
<span class="legend__item"><i class="chip chip--mid"></i> 60–84</span>
<span class="legend__item"><i class="chip chip--low"></i> < 60</span>
<span class="legend__item"><i class="chip chip--pending"></i> Awaiting grade</span>
<span class="legend__item"><i class="chip chip--missing"></i> Missing</span>
</section>
<div class="matrix-wrap">
<table class="matrix" id="matrix">
<thead id="matrixHead"></thead>
<tbody id="matrixBody"></tbody>
</table>
<p class="empty" id="emptyState" hidden>No students match this filter.</p>
</div>
</main>
</div>
<!-- Grading drawer -->
<div class="scrim" id="scrim" hidden></div>
<aside class="drawer" id="drawer" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="drawerTitle">
<header class="drawer__head">
<div>
<p class="drawer__eyebrow" id="drawerEyebrow">Assignment</p>
<h2 id="drawerTitle">Submission</h2>
</div>
<button class="icon-btn" id="closeDrawer" aria-label="Close drawer">×</button>
</header>
<div class="drawer__body">
<div class="submission" id="submission"></div>
<h3 class="drawer__h3">Rubric</h3>
<div class="rubric" id="rubric"></div>
<div class="rubric-total">
<span>Rubric total</span>
<strong id="rubricTotal">0 / 0</strong>
</div>
<label class="drawer__label" for="feedback">Feedback to student</label>
<textarea id="feedback" rows="4" placeholder="What went well, what to improve next time…"></textarea>
<div class="grade-row">
<label for="gradeInput">Final grade</label>
<div class="grade-input">
<input id="gradeInput" type="number" min="0" max="100" step="1" inputmode="numeric" />
<span>/ 100</span>
</div>
<button class="ghost-btn" id="useRubric" type="button">Use rubric score</button>
</div>
</div>
<footer class="drawer__foot">
<button class="ghost-btn" id="markMissing" type="button">Mark missing</button>
<button class="primary-btn" id="saveGrade" type="button">Save grade</button>
</footer>
</aside>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Gradebook
An instructor-facing gradebook for an online course. The screen is a scrollable matrix of students down the side and assignments across the top, with a sticky first column and a header row that carries each assignment’s weight and live class average. Every cell is color-coded — green for strong scores, amber for borderline, red for missing, and a soft slate for submissions still waiting to be graded — so the state of the whole cohort reads at a glance. A toolbar offers a cohort dropdown, a name search, and summary stats for the filtered view.
Clicking a cell opens a grading drawer for that student and assignment. The drawer shows the submission preview, a weighted rubric you can score criterion by criterion, a feedback textarea, and a final grade field. Saving writes the new score back into its cell, repaints the color band, and recomputes both the per-assignment average and the overall class average instantly. Rubric points auto-total into a suggested grade, and a toast confirms each save.
Everything is vanilla JavaScript with no dependencies, keyboard-usable, and responsive down to ~360px, where the toolbar stacks and the matrix scrolls horizontally while the student column stays pinned.
Illustrative UI only — fictional courses, not a real learning platform.