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:
1 parent
723bbb93c8
commit
de598496bc
3 files changed
+104
-27
No files matched your search
+75
-11
@@ -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) => {
|
||||
|
||||
// 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 = gb.gradableItems
|
||||
.map((it) => {
|
||||
|
||||
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('');
|
||||
}).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('');
|
||||
|
||||
// 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>${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>`;
|
||||
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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user