From de598496bcdeb44d898dc4d24f6b6260c120c07c Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:43:15 -0400 Subject: [PATCH] Gradebook unit cards, scroll/state preservation on course edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- public/admin.js | 104 +++++++++++++++++++++++++++++++++++++--------- public/styles.css | 42 ++++++++++++------- server.js | 3 +- 3 files changed, 113 insertions(+), 36 deletions(-) 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 `
+ +
+
+ ${header}${rows}
Kid
+
+
+
`; }).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 = `${header}${rows}
KidOverall
`; + + // 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}
+
+ Course overall + ${overallRows}
Kid%
+
`; + + 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