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) {
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 = `<p class="hint">Need at least one enrolled kid and one graded item (assignment, quiz, or spelling test) to show a gradebook.</p>`;
return;
}
const header = gb.gradableItems.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>`;
// 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 ? `<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('');
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 `<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('');
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>`;
})
.join('');
$('#gradebook-table').innerHTML = `<table class="results"><tr><th>Kid</th>${header}<th>Overall</th></tr>${rows}</table>`;
// Overall course summary
const overallRows = gb.students.map((s) => {
const pctClass = s.percent === null ? '' : s.percent >= 80 ? 'score-good' : 'score-bad';
return `<tr><td>${esc(s.emoji)} ${esc(s.name)}</td><td class="${pctClass}">${s.percent === null ? '—' : s.percent + '%'}</td></tr>`;
}).join('');
$('#gradebook-table').innerHTML = `<div class="gb-units">${unitBlocks}</div>
<div class="gb-overall">
<strong>Course overall</strong>
<table class="results"><tr><th>Kid</th><th>%</th></tr>${overallRows}</table>
</div>`;
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))
+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); }
.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 {
+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' });
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