diff --git a/public/admin.js b/public/admin.js
index b7bf7f6..69fc2ea 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -208,6 +208,14 @@ $('#add-course-form').addEventListener('submit', async (e) => {
async function openCourseDetail(id) {
if (!id) return showPanel('courses');
+
+ // Preserve unit open/collapsed states and scroll position before re-rendering
+ const existingBlocks = document.querySelectorAll('#cd-units .unit-block');
+ const savedOpen = Array.from(existingBlocks).map((b) => b.classList.contains('open'));
+ const panelVisible = !document.getElementById('panel-course-detail').hidden;
+ const savedScroll = panelVisible && existingBlocks.length ? window.scrollY : 0;
+ const sameCoursePrev = existingBlocks.length > 0 && id === currentCourseId;
+
const [course, roster] = await Promise.all([
api(`/api/admin/courses/${id}`),
api(`/api/courses/${id}/roster`),
@@ -289,6 +297,18 @@ async function openCourseDetail(id) {
$('#cd-print').onclick = () => printCourse(id);
+ // Restore which units were open and the scroll position
+ if (sameCoursePrev && savedOpen.length) {
+ document.querySelectorAll('#cd-units .unit-block').forEach((block, i) => {
+ if (i < savedOpen.length) {
+ block.classList.toggle('open', savedOpen[i]);
+ const btn = block.querySelector('.unit-toggle');
+ if (btn) btn.setAttribute('aria-expanded', String(savedOpen[i]));
+ }
+ });
+ }
+ if (savedScroll > 0) requestAnimationFrame(() => window.scrollTo(0, savedScroll));
+
document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== 'panel-course-detail'));
document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.remove('active'));
}
@@ -1361,27 +1381,71 @@ async function renderGradebook(courseId) {
$('#gradebook-table').innerHTML = `
Need at least one enrolled kid and one graded item (assignment, quiz, or spelling test) to show a gradebook.
`;
return;
}
- const header = gb.gradableItems.map((it) => {
- const due = it.due_date ? `
due ${it.due_date}` : '';
- return `${esc(it.title)} ${it.points} pts${due} | `;
+
+ // Group items by unit (preserving unit order from the API)
+ const unitMap = new Map();
+ for (const it of gb.gradableItems) {
+ if (!unitMap.has(it.unit_id)) unitMap.set(it.unit_id, { id: it.unit_id, name: it.unit_name, items: [] });
+ unitMap.get(it.unit_id).items.push(it);
+ }
+ const units = Array.from(unitMap.values());
+
+ const unitBlocks = units.map((u, uIdx) => {
+ const header = u.items.map((it) => {
+ const due = it.due_date ? `
due ${it.due_date}` : '';
+ return `${esc(it.title)} ${it.points} pts${due} | `;
+ }).join('');
+
+ const rows = gb.students.map((s) => {
+ const cells = u.items.map((it) => {
+ const sc = s.scores[it.id];
+ const overdueClass = sc?.overdue ? ' overdue' : '';
+ if (!sc || (!sc.status && !sc.overdue)) return `— | `;
+ if (sc.overdue && !sc.status) return `⚠️ | `;
+ if (sc.status !== 'graded') return `${sc.overdue ? '⚠️ ' : ''}⏳ | `;
+ return `${sc.score}/${sc.points_possible} | `;
+ }).join('');
+ return `| ${esc(s.emoji)} ${esc(s.name)} | ${cells}
`;
+ }).join('');
+
+ return `
+
+
+
`;
}).join('');
- const rows = gb.students
- .map((s) => {
- const cells = gb.gradableItems
- .map((it) => {
- const sc = s.scores[it.id];
- const overdueClass = sc?.overdue ? ' overdue' : '';
- if (!sc || (!sc.status && !sc.overdue)) return `— | `;
- if (sc.overdue && !sc.status) return `⚠️ | `;
- if (sc.status !== 'graded') return `${sc.overdue ? '⚠️ ' : ''}⏳ | `;
- return `${sc.score}/${sc.points_possible} | `;
- })
- .join('');
- const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
- return `| ${esc(s.emoji)} ${esc(s.name)} | ${cells}${s.percent === null ? '—' : s.percent + '%'} |
`;
- })
- .join('');
- $('#gradebook-table').innerHTML = `| Kid | ${header}Overall |
${rows}
`;
+
+ // Overall course summary
+ const overallRows = gb.students.map((s) => {
+ const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
+ return `| ${esc(s.emoji)} ${esc(s.name)} | ${s.percent === null ? '—' : s.percent + '%'} |
`;
+ }).join('');
+
+ $('#gradebook-table').innerHTML = `${unitBlocks}
+ `;
+
+ document.querySelectorAll('#gradebook-table .unit-toggle').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const block = btn.closest('.unit-block');
+ const nowOpen = block.classList.toggle('open');
+ btn.setAttribute('aria-expanded', String(nowOpen));
+ });
+ });
document.querySelectorAll('[data-history-student]').forEach((td) =>
td.addEventListener('click', () => showHistory(td.dataset.historyStudent, td.dataset.historyItem, td.dataset.historyLabel))
diff --git a/public/styles.css b/public/styles.css
index 52204f6..8f49aa0 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -283,50 +283,62 @@ table.results tr:nth-child(even) td { background: #faf7ee; }
.course-pct { font-size: 0.85rem; color: var(--muted); }
.progress-track.small { height: 8px; margin: 0.2rem 0; }
-/* student course unit cards */
-#course-units .unit-block {
+/* student course unit cards + gradebook unit cards (shared button-toggle style) */
+#course-units .unit-block,
+.gb-units .unit-block {
background: var(--card); border-radius: 16px; margin: 0.75rem 0;
box-shadow: 0 2px 10px rgba(45, 49, 66, 0.08); overflow: hidden;
}
-#course-units .unit-toggle {
+#course-units .unit-toggle,
+.gb-units .unit-toggle {
display: flex; align-items: center; justify-content: space-between;
width: 100%; background: none; border: none; padding: 1rem 1.1rem;
cursor: pointer; font-family: inherit; text-align: left; gap: 0.75rem;
border-bottom: 2px solid transparent; transition: border-color 0.15s;
}
-#course-units .unit-block.open .unit-toggle { border-bottom-color: var(--accent); }
-#course-units .unit-toggle-left {
+#course-units .unit-block.open .unit-toggle,
+.gb-units .unit-block.open .unit-toggle { border-bottom-color: var(--accent); }
+#course-units .unit-toggle-left,
+.gb-units .unit-toggle-left {
display: flex; align-items: center; gap: 0.6rem; flex: 1; min-width: 0;
}
-#course-units .unit-num-badge {
+#course-units .unit-num-badge,
+.gb-units .unit-num-badge {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; background: var(--accent); color: #fff;
padding: 0.2em 0.55em; border-radius: 6px; flex-shrink: 0;
}
-#course-units .unit-title-text {
+#course-units .unit-title-text,
+.gb-units .unit-title-text {
font-size: 1.05rem; font-weight: 700; color: var(--ink);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
-#course-units .unit-toggle-right {
+#course-units .unit-toggle-right,
+.gb-units .unit-toggle-right {
display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;
}
-#course-units .unit-done-count {
- font-size: 0.82rem; font-weight: 700; color: var(--muted); white-space: nowrap;
-}
+#course-units .unit-done-count { font-size: 0.82rem; font-weight: 700; color: var(--muted); white-space: nowrap; }
#course-units .unit-mini-progress { width: 56px; height: 6px; margin: 0; }
-#course-units .unit-chevron {
+#course-units .unit-chevron,
+.gb-units .unit-chevron {
font-size: 1.15rem; color: var(--muted); display: inline-block;
transition: transform 0.22s ease;
}
-#course-units .unit-block:not(.open) .unit-chevron { transform: rotate(-90deg); }
-#course-units .unit-items-list {
+#course-units .unit-block:not(.open) .unit-chevron,
+.gb-units .unit-block:not(.open) .unit-chevron { transform: rotate(-90deg); }
+#course-units .unit-items-list,
+.gb-units .unit-items-list {
max-height: 0; overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease;
padding: 0 0.85rem;
}
-#course-units .unit-block.open .unit-items-list {
+#course-units .unit-block.open .unit-items-list,
+.gb-units .unit-block.open .unit-items-list {
max-height: 4000px; padding: 0.1rem 0.85rem 0.85rem;
}
+.gb-units .unit-item-count { font-size: 0.82rem; font-weight: 700; color: var(--muted); white-space: nowrap; }
+.gb-overall { margin-top: 1.25rem; }
+.gb-overall strong { display: block; font-size: 0.9rem; color: var(--muted); margin-bottom: 0.4rem; }
/* admin course unit cards */
#cd-units .unit-block {
diff --git a/server.js b/server.js
index 0323e09..930deb0 100644
--- a/server.js
+++ b/server.js
@@ -884,7 +884,8 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
if (!course) return res.status(404).json({ error: 'No such course' });
const gradableItems = db.prepare(`
- SELECT i.id, i.title, i.type, i.points, i.due_date, i.retake_policy
+ SELECT i.id, i.title, i.type, i.points, i.due_date, i.retake_policy,
+ u.id AS unit_id, u.name AS unit_name, u.sort AS unit_sort
FROM items i JOIN units u ON u.id = i.unit_id
WHERE u.course_id = ? AND i.type IN (${GRADABLE_TYPES.map(() => '?').join(',')})
ORDER BY u.sort, i.sort, i.id