Gradebook unit cards, scroll/state preservation on course edit

- Gradebook now groups items by unit with collapsible cards (same card
  style as course detail) and a Course overall summary table at the bottom
- server.js: add unit_id/unit_name to gradebook API so items can be grouped
- Course detail: preserve which units are open and scroll position across
  any in-place re-render (add unit, delete unit, save meta, return from
  item editor) — no more jumping to the top with everything reset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-07-29 16:43:15 -04:00
1 parent 723bbb93c8
commit de598496bc
3 files changed
+113 -36

No files matched your search

+84 -20
View File
@@ -208,6 +208,14 @@ $('#add-course-form').addEventListener('submit', async (e) => {
async function openCourseDetail(id) { async function openCourseDetail(id) {
if (!id) return showPanel('courses'); 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([ const [course, roster] = await Promise.all([
api(`/api/admin/courses/${id}`), api(`/api/admin/courses/${id}`),
api(`/api/courses/${id}/roster`), api(`/api/courses/${id}/roster`),
@@ -289,6 +297,18 @@ async function openCourseDetail(id) {
$('#cd-print').onclick = () => printCourse(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('.panel').forEach((p) => (p.hidden = p.id !== 'panel-course-detail'));
document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.remove('active')); document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.remove('active'));
} }
@@ -1361,27 +1381,71 @@ async function renderGradebook(courseId) {
$('#gradebook-table').innerHTML = `<p class="hint">Need at least one enrolled kid and one graded item (assignment, quiz, or spelling test) to show a gradebook.</p>`; $('#gradebook-table').innerHTML = `<p class="hint">Need at least one enrolled kid and one graded item (assignment, quiz, or spelling test) to show a gradebook.</p>`;
return; return;
} }
const header = gb.gradableItems.map((it) => {
const due = it.due_date ? `<br><small class="hint">due ${it.due_date}</small>` : ''; // Group items by unit (preserving unit order from the API)
return `<th>${esc(it.title)}<br><small>${it.points} pts</small>${due}</th>`; 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 ? `<br><small class="hint">due ${it.due_date}</small>` : '';
return `<th>${esc(it.title)}<br><small>${it.points} pts</small>${due}</th>`;
}).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 `<td class="hint">—</td>`;
if (sc.overdue && !sc.status) return `<td class="hint overdue" title="Overdue">⚠️</td>`;
if (sc.status !== 'graded') return `<td class="hint${overdueClass}">${sc.overdue ? '⚠️ ' : ''}⏳</td>`;
return `<td class="${sc.score / sc.points_possible >= 0.8 ? 'score-good' : 'score-bad'}${overdueClass}" style="cursor:pointer" data-history-student="${s.id}" data-history-item="${it.id}" data-history-label="${esc(it.title)} — ${esc(s.name)}">${sc.score}/${sc.points_possible}</td>`;
}).join('');
return `<tr><td>${esc(s.emoji)} ${esc(s.name)}</td>${cells}</tr>`;
}).join('');
return `<div class="unit-block open">
<button class="unit-toggle" type="button" aria-expanded="true">
<div class="unit-toggle-left">
<span class="unit-num-badge">Unit ${uIdx + 1}</span>
<span class="unit-title-text">${esc(u.name)}</span>
</div>
<div class="unit-toggle-right">
<span class="unit-item-count">${u.items.length} item${u.items.length !== 1 ? 's' : ''}</span>
<span class="unit-chevron" aria-hidden="true">▾</span>
</div>
</button>
<div class="unit-items-list">
<div style="overflow-x:auto">
<table class="results"><tr><th>Kid</th>${header}</tr>${rows}</table>
</div>
</div>
</div>`;
}).join(''); }).join('');
const rows = gb.students
.map((s) => { // Overall course summary
const cells = gb.gradableItems const overallRows = gb.students.map((s) => {
.map((it) => { const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
const sc = s.scores[it.id]; return `<tr><td>${esc(s.emoji)} ${esc(s.name)}</td><td class="${pctClass}">${s.percent === null ? '—' : s.percent + '%'}</td></tr>`;
const overdueClass = sc?.overdue ? ' overdue' : ''; }).join('');
if (!sc || (!sc.status && !sc.overdue)) return `<td class="hint">—</td>`;
if (sc.overdue && !sc.status) return `<td class="hint overdue" title="Overdue">⚠️</td>`; $('#gradebook-table').innerHTML = `<div class="gb-units">${unitBlocks}</div>
if (sc.status !== 'graded') return `<td class="hint${overdueClass}">${sc.overdue ? '⚠️ ' : ''}⏳</td>`; <div class="gb-overall">
return `<td class="${sc.score / sc.points_possible >= 0.8 ? 'score-good' : 'score-bad'}${overdueClass}" style="cursor:pointer" data-history-student="${s.id}" data-history-item="${it.id}" data-history-label="${esc(it.title)} — ${esc(s.name)}">${sc.score}/${sc.points_possible}</td>`; <strong>Course overall</strong>
}) <table class="results"><tr><th>Kid</th><th>%</th></tr>${overallRows}</table>
.join(''); </div>`;
const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
return `<tr><td>${esc(s.emoji)} ${esc(s.name)}</td>${cells}<td class="${pctClass}">${s.percent === null ? '—' : s.percent + '%'}</td></tr>`; document.querySelectorAll('#gradebook-table .unit-toggle').forEach((btn) => {
}) btn.addEventListener('click', () => {
.join(''); const block = btn.closest('.unit-block');
$('#gradebook-table').innerHTML = `<table class="results"><tr><th>Kid</th>${header}<th>Overall</th></tr>${rows}</table>`; const nowOpen = block.classList.toggle('open');
btn.setAttribute('aria-expanded', String(nowOpen));
});
});
document.querySelectorAll('[data-history-student]').forEach((td) => document.querySelectorAll('[data-history-student]').forEach((td) =>
td.addEventListener('click', () => showHistory(td.dataset.historyStudent, td.dataset.historyItem, td.dataset.historyLabel)) td.addEventListener('click', () => showHistory(td.dataset.historyStudent, td.dataset.historyItem, td.dataset.historyLabel))
+27 -15
View File
@@ -283,50 +283,62 @@ table.results tr:nth-child(even) td { background: #faf7ee; }
.course-pct { font-size: 0.85rem; color: var(--muted); } .course-pct { font-size: 0.85rem; color: var(--muted); }
.progress-track.small { height: 8px; margin: 0.2rem 0; } .progress-track.small { height: 8px; margin: 0.2rem 0; }
/* student course unit cards */ /* student course unit cards + gradebook unit cards (shared button-toggle style) */
#course-units .unit-block { #course-units .unit-block,
.gb-units .unit-block {
background: var(--card); border-radius: 16px; margin: 0.75rem 0; background: var(--card); border-radius: 16px; margin: 0.75rem 0;
box-shadow: 0 2px 10px rgba(45, 49, 66, 0.08); overflow: hidden; 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; display: flex; align-items: center; justify-content: space-between;
width: 100%; background: none; border: none; padding: 1rem 1.1rem; width: 100%; background: none; border: none; padding: 1rem 1.1rem;
cursor: pointer; font-family: inherit; text-align: left; gap: 0.75rem; cursor: pointer; font-family: inherit; text-align: left; gap: 0.75rem;
border-bottom: 2px solid transparent; transition: border-color 0.15s; 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-block.open .unit-toggle,
#course-units .unit-toggle-left { .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; 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; font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; background: var(--accent); color: #fff; letter-spacing: 0.05em; background: var(--accent); color: #fff;
padding: 0.2em 0.55em; border-radius: 6px; flex-shrink: 0; 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); font-size: 1.05rem; font-weight: 700; color: var(--ink);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; 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; display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;
} }
#course-units .unit-done-count { #course-units .unit-done-count { font-size: 0.82rem; font-weight: 700; color: var(--muted); white-space: nowrap; }
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-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; font-size: 1.15rem; color: var(--muted); display: inline-block;
transition: transform 0.22s ease; transition: transform 0.22s ease;
} }
#course-units .unit-block:not(.open) .unit-chevron { transform: rotate(-90deg); } #course-units .unit-block:not(.open) .unit-chevron,
#course-units .unit-items-list { .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; max-height: 0; overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease; transition: max-height 0.3s ease, padding 0.3s ease;
padding: 0 0.85rem; 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; 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 */ /* admin course unit cards */
#cd-units .unit-block { #cd-units .unit-block {
+2 -1
View File
@@ -884,7 +884,8 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => {
if (!course) return res.status(404).json({ error: 'No such course' }); if (!course) return res.status(404).json({ error: 'No such course' });
const gradableItems = db.prepare(` 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 FROM items i JOIN units u ON u.id = i.unit_id
WHERE u.course_id = ? AND i.type IN (${GRADABLE_TYPES.map(() => '?').join(',')}) WHERE u.course_id = ? AND i.type IN (${GRADABLE_TYPES.map(() => '?').join(',')})
ORDER BY u.sort, i.sort, i.id ORDER BY u.sort, i.sort, i.id