From 02909e1edcbe0baa29f41b32b6b4cad4227169f0 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:22:58 -0400 Subject: [PATCH] Add retakes, quiz templates, score history, due dates, CSV export, and prerequisites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - allow_retakes flag on items: quizzes, spelling tests, and assignments can all be retaken - Quiz templates: save any quiz as a reusable template and load it into new items - Score history: every quiz/test attempt logged to submission_history; click a gradebook score to see all attempts - Due dates: optional due date per item, shown in course view and gradebook (overdue flagged with โš ๏ธ) - Gradebook CSV export: one-click download of any course gradebook - Prerequisites: lock an item until a prior one is completed; locked items show ๐Ÿ”’ in course view Co-Authored-By: Claude Sonnet 4.6 --- db.js | 32 +++++++++ public/admin.html | 24 ++++++- public/admin.js | 96 +++++++++++++++++++++++++-- public/app.js | 74 +++++++++++++++++---- public/styles.css | 24 +++++++ server.js | 164 ++++++++++++++++++++++++++++++++++++++++------ 6 files changed, 375 insertions(+), 39 deletions(-) diff --git a/db.js b/db.js index 8041206..770bf6f 100644 --- a/db.js +++ b/db.js @@ -181,8 +181,40 @@ db.exec(` sort INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_schedule_day ON schedule(student_id, date); + + CREATE TABLE IF NOT EXISTS quiz_templates ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS quiz_template_questions ( + id INTEGER PRIMARY KEY, + template_id INTEGER NOT NULL REFERENCES quiz_templates(id) ON DELETE CASCADE, + type TEXT NOT NULL, + prompt TEXT NOT NULL, + choices TEXT NOT NULL DEFAULT '[]', + correct_answer TEXT NOT NULL, + points INTEGER NOT NULL DEFAULT 1, + sort INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS submission_history ( + id INTEGER PRIMARY KEY, + student_id INTEGER NOT NULL REFERENCES students(id) ON DELETE CASCADE, + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + score REAL, + points_possible REAL, + answers TEXT, + completed_at TEXT NOT NULL DEFAULT (datetime('now')) + ); `); +function tryAlter(sql) { + try { db.exec(sql); } catch { /* column already exists */ } +} +tryAlter(`ALTER TABLE items ADD COLUMN due_date TEXT`); +tryAlter(`ALTER TABLE items ADD COLUMN allow_retakes INTEGER NOT NULL DEFAULT 0`); +tryAlter(`ALTER TABLE items ADD COLUMN prereq_item_id INTEGER`); + export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex'); // Default parent PIN on first run diff --git a/public/admin.html b/public/admin.html index 7ea3569..e94b455 100644 --- a/public/admin.html +++ b/public/admin.html @@ -113,6 +113,13 @@ +
+ + +
+
+
+ + + +
@@ -182,8 +194,18 @@

Gradebook

- +
+ + +
+
diff --git a/public/admin.js b/public/admin.js index 1ba8151..bcb63b1 100644 --- a/public/admin.js +++ b/public/admin.js @@ -263,12 +263,31 @@ $('#add-unit-form').addEventListener('submit', async (e) => { // ============================================================ let questionCount = 0; +let cachedQuizTemplates = []; async function openItemEditor(unitId, itemId) { - [cachedLists, cachedDecks] = await Promise.all([api('/api/lists'), api('/api/decks')]); + let courseDetail; + [cachedLists, cachedDecks, cachedQuizTemplates, courseDetail] = await Promise.all([ + api('/api/lists'), + api('/api/decks'), + api('/api/quiz-templates'), + api(`/api/admin/courses/${currentCourseId}`), + ]); $('#ie-list').innerHTML = cachedLists.map((l) => ``).join(''); $('#ie-deck').innerHTML = cachedDecks.map((d) => ``).join(''); + // Populate template select + $('#ie-template-select').innerHTML = `` + + cachedQuizTemplates.map((t) => ``).join(''); + + // Populate prereq select (all items in course except this one) + const allCourseItems = courseDetail.units.flatMap((u) => u.items); + $('#ie-prereq').innerHTML = `` + + allCourseItems + .filter((it) => it.id !== Number(itemId)) + .map((it) => ``) + .join(''); + $('#ie-id').value = itemId || ''; $('#ie-unit-id').value = unitId; $('#ie-error').hidden = true; @@ -283,6 +302,9 @@ async function openItemEditor(unitId, itemId) { $('#ie-body-lesson').value = item.body; $('#ie-body-assignment').value = item.body; $('#ie-points').value = item.points || 10; + $('#ie-due-date').value = item.due_date || ''; + $('#ie-allow-retakes').checked = !!item.allow_retakes; + $('#ie-prereq').value = item.prereq_item_id || ''; if (item.type === 'spelling_practice' || item.type === 'spelling_test') $('#ie-list').value = item.ref_id; if (item.type === 'flashcards') $('#ie-deck').value = item.ref_id; if (item.type === 'quiz') (item.questions || []).forEach(addQuestionRow); @@ -299,6 +321,9 @@ async function openItemEditor(unitId, itemId) { $('#ie-body-lesson').value = ''; $('#ie-body-assignment').value = ''; $('#ie-points').value = 10; + $('#ie-due-date').value = ''; + $('#ie-allow-retakes').checked = false; + $('#ie-prereq').value = ''; $('#ie-delete').hidden = true; } updateItemFieldVisibility(); @@ -423,10 +448,35 @@ function addQuestionRow(q = {}) { } $('#ie-add-question').addEventListener('click', () => addQuestionRow()); +$('#ie-load-template').addEventListener('click', async () => { + const tid = $('#ie-template-select').value; + if (!tid) return msg('Pick a template first.'); + const t = await api(`/api/quiz-templates/${tid}`); + $('#ie-questions').innerHTML = ''; + questionCount = 0; + t.questions.forEach((q) => addQuestionRow({ ...q, correct_answer: q.correct_answer })); + msg(`Loaded "${t.name}" โ€” ${t.questions.length} question(s).`); +}); + +$('#ie-save-template').addEventListener('click', async () => { + const itemId = $('#ie-id').value; + if (!itemId) return msg('Save the item first, then save as template.'); + const name = prompt('Template name:'); + if (!name) return; + try { + await api('/api/quiz-templates', { method: 'POST', body: { name, itemId: Number(itemId) } }); + msg(`Template "${name}" saved.`); + cachedQuizTemplates = await api('/api/quiz-templates'); + $('#ie-template-select').innerHTML = `` + + cachedQuizTemplates.map((t) => ``).join(''); + } catch (err) { msg(err.message); } +}); + $('#item-form').addEventListener('submit', async (e) => { e.preventDefault(); const type = $('#ie-type').value; const title = $('#ie-title').value.trim(); + const prereqVal = $('#ie-prereq').value; const body = { unitId: Number($('#ie-unit-id').value), type, @@ -435,6 +485,9 @@ $('#item-form').addEventListener('submit', async (e) => { points: type === 'assignment' ? Number($('#ie-points').value) || 0 : 0, refId: type === 'spelling_practice' || type === 'spelling_test' ? Number($('#ie-list').value) : type === 'flashcards' ? Number($('#ie-deck').value) : null, + dueDate: $('#ie-due-date').value || null, + allowRetakes: $('#ie-allow-retakes').checked, + prereqItemId: prereqVal ? Number(prereqVal) : null, }; if (type === 'quiz') { body.questions = Array.from(document.querySelectorAll('.quiz-q-row')).map((row) => ({ @@ -660,29 +713,58 @@ async function loadGrading() { // Gradebook // ============================================================ +let gradebookCourseId = null; + async function loadGradebookPanel() { const courses = await api('/api/admin/courses'); $('#gradebook-course').innerHTML = courses.map((c) => ``).join(''); if (courses.length) renderGradebook(courses[0].id); } $('#gradebook-course').addEventListener('change', () => renderGradebook($('#gradebook-course').value)); +$('#gradebook-csv-btn').addEventListener('click', () => { + if (!gradebookCourseId) return; + window.location.href = `/api/gradebook/${gradebookCourseId}/csv`; +}); +$('#history-modal-close').addEventListener('click', () => { $('#history-modal').hidden = true; }); + +async function showHistory(studentId, itemId, label) { + const { history } = await api(`/api/items/${itemId}/history?studentId=${studentId}`); + $('#history-modal-title').textContent = `Attempts: ${label}`; + if (!history.length) { + $('#history-modal-body').innerHTML = `

No recorded attempts yet.

`; + } else { + const rows = history.map((h) => { + const pct = h.points_possible ? Math.round((h.score / h.points_possible) * 100) : null; + const cls = pct !== null ? (pct >= 80 ? 'score-good' : 'score-bad') : ''; + return `${new Date(h.completed_at + 'Z').toLocaleString()}${h.score}/${h.points_possible}${pct !== null ? ` (${pct}%)` : ''}`; + }).join(''); + $('#history-modal-body').innerHTML = `${rows}
DateScore
`; + } + $('#history-modal').hidden = false; +} async function renderGradebook(courseId) { + gradebookCourseId = courseId || null; if (!courseId) return ($('#gradebook-table').innerHTML = ''); const gb = await api(`/api/gradebook/${courseId}`); if (gb.gradableItems.length === 0 || gb.students.length === 0) { $('#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) => `${esc(it.title)}
${it.points} pts`).join(''); + const header = gb.gradableItems.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 = gb.gradableItems .map((it) => { const sc = s.scores[it.id]; - if (!sc) return `โ€”`; - if (sc.status !== 'graded') return `โณ`; - return `${sc.score}/${sc.points_possible}`; + 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'; @@ -690,6 +772,10 @@ async function renderGradebook(courseId) { }) .join(''); $('#gradebook-table').innerHTML = `${header}${rows}
KidOverall
`; + + document.querySelectorAll('[data-history-student]').forEach((td) => + td.addEventListener('click', () => showHistory(td.dataset.historyStudent, td.dataset.historyItem, td.dataset.historyLabel)) + ); } // ============================================================ diff --git a/public/app.js b/public/app.js index 06497dd..a903394 100644 --- a/public/app.js +++ b/public/app.js @@ -233,10 +233,18 @@ async function openCourse(courseId) {

${esc(u.name)}

${u.items .map((it) => { + if (it.locked) { + return `
+ ๐Ÿ”’ + ${esc(it.title)}${TYPE_LABEL[it.type]} + Complete previous item first +
`; + } const badge = statusBadge(it.type, it.status, it.score, it.points_possible, it.status !== 'not_started'); + const due = it.due_date ? `Due ${it.due_date}` : ''; return ``; }) @@ -244,7 +252,7 @@ async function openCourse(courseId) { ` ) .join(''); - document.querySelectorAll('#course-units .today-row').forEach((row) => + document.querySelectorAll('#course-units .today-row:not(.locked)').forEach((row) => row.addEventListener('click', () => { backTarget = () => openCourse(courseId); openItem(Number(row.dataset.itemId), row.dataset.itemType); @@ -258,10 +266,10 @@ async function openCourse(courseId) { async function openItem(itemId, type) { if (type === 'lesson') return openLesson(itemId); if (type === 'assignment') return openAssignment(itemId); - if (type === 'quiz') return openQuiz(itemId); + if (type === 'quiz') return openQuiz(itemId, false); const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); if (type === 'spelling_practice') return startPractice({ listId: item.list.id, itemId }); - if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId }); + if (type === 'spelling_test') return startTest({ listId: item.list.id, itemId, allowRetakes: item.allow_retakes }); if (type === 'flashcards') return startFlashcards({ deckId: item.deck.id, deckName: item.deck.name, itemId }); } @@ -294,10 +302,19 @@ async function openAssignment(itemId) { const btn = $('#assignment-done-btn'); const status = $('#assignment-status'); if (item.submission && item.submission.status === 'graded') { - btn.hidden = true; status.hidden = false; status.className = 'status-banner good'; status.textContent = `๐ŸŒŸ Graded: ${item.submission.score} / ${item.submission.points_possible}`; + if (item.allow_retakes) { + btn.hidden = false; + btn.textContent = '๐Ÿ” Retake'; + btn.onclick = async () => { + await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } }); + openAssignment(itemId); + }; + } else { + btn.hidden = true; + } } else if (item.submission && item.submission.status === 'done') { btn.hidden = true; status.hidden = false; @@ -305,6 +322,7 @@ async function openAssignment(itemId) { status.textContent = `โณ Turned in โ€” waiting for a parent to grade it.`; } else { btn.hidden = false; + btn.textContent = 'Mark as done โœ“'; status.hidden = true; btn.onclick = async () => { await api(`/api/items/${itemId}/complete`, { method: 'POST', body: { studentId: currentStudent.id, date: todayStr() } }); @@ -316,18 +334,34 @@ async function openAssignment(itemId) { // ---------- quiz ---------- -async function openQuiz(itemId) { +async function openQuiz(itemId, forceRetake = false) { const item = await api(`/api/items/${itemId}?studentId=${currentStudent.id}`); $('#quiz-kicker').textContent = `${item.course_name} ยท ${item.unit_name}`; $('#quiz-title').textContent = item.title; - const graded = item.submission && item.submission.status === 'graded'; + const graded = !forceRetake && item.submission && item.submission.status === 'graded'; const result = $('#quiz-result'); if (graded) { result.hidden = false; result.className = 'status-banner good'; - result.textContent = `๐ŸŒŸ Score: ${item.submission.score} / ${item.submission.points_possible}`; - $('#quiz-form').innerHTML = item.questions + const pct = item.submission.points_possible ? Math.round((item.submission.score / item.submission.points_possible) * 100) : 0; + result.innerHTML = `๐ŸŒŸ Score: ${item.submission.score} / ${item.submission.points_possible} (${pct}%)`; + + // Attempt history + const { history } = await api(`/api/items/${itemId}/history?studentId=${currentStudent.id}`); + let historyHtml = ''; + if (history.length > 1) { + const histRows = history.map((h) => { + const hp = h.points_possible ? Math.round((h.score / h.points_possible) * 100) : 0; + return `${new Date(h.completed_at + 'Z').toLocaleString()}${h.score}/${h.points_possible} (${hp}%)`; + }).join(''); + historyHtml = `
All attempts (${history.length})${histRows}
`; + } + + let retakeBtn = ''; + if (item.allow_retakes) retakeBtn = ``; + + $('#quiz-form').innerHTML = historyHtml + retakeBtn + item.questions .map((q) => { const correct = normalizeAnswer(q.given) === normalizeAnswer(q.correct_answer); return `
@@ -337,6 +371,10 @@ async function openQuiz(itemId) {
`; }) .join(''); + + if (item.allow_retakes) { + $('#quiz-retake-btn').addEventListener('click', () => openQuiz(itemId, true)); + } } else { result.hidden = true; $('#quiz-form').innerHTML = @@ -380,7 +418,7 @@ async function openQuiz(itemId) { method: 'POST', body: { studentId: currentStudent.id, answers, date: todayStr() }, }); - openQuiz(itemId); + openQuiz(itemId, false); }; } show('quiz'); @@ -567,12 +605,12 @@ async function nextPracticeWord() { // ---------- spelling: test ---------- -const test = { list: null, words: [], i: 0, answers: [], itemId: null }; +const test = { list: null, words: [], i: 0, answers: [], itemId: null, allowRetakes: false }; -async function startTest({ listId, itemId } = {}) { +async function startTest({ listId, itemId, allowRetakes = false } = {}) { const url = listId ? `/api/test/${currentStudent.id}?listId=${listId}` : `/api/test/${currentStudent.id}`; const data = await api(url); - Object.assign(test, { list: data.list, words: data.words, i: 0, answers: [], itemId: itemId || null }); + Object.assign(test, { list: data.list, words: data.words, i: 0, answers: [], itemId: itemId || null, allowRetakes: !!allowRetakes }); $('#test-card').hidden = false; $('#test-done').hidden = true; show('test'); @@ -617,13 +655,23 @@ async function finishTest() { : `
  • โŒ ${esc(g.word)} (you wrote "${esc(g.typed)}")
  • ` ) .join(''); + + let retakeBtn = ''; + if (test.itemId && test.allowRetakes) retakeBtn = ``; + $('#test-done').innerHTML = `
    โญ Test complete!
    ${result.score} / ${result.total}
    + ${retakeBtn} `; $('#test-done').hidden = false; $('#test-home').addEventListener('click', () => backTarget()); + if (test.itemId && test.allowRetakes) { + $('#test-retake-btn').addEventListener('click', () => { + openItem(test.itemId, 'spelling_test'); + }); + } } $('#btn-practice').addEventListener('click', () => { diff --git a/public/styles.css b/public/styles.css index ea5e71c..e98716c 100644 --- a/public/styles.css +++ b/public/styles.css @@ -392,3 +392,27 @@ button.tiny { font-size: 0.75rem; padding: 0.15rem 0.5rem; } .scan-provider-box legend { font-weight: 600; padding: 0 0.3rem; } .radio-label { display: block; margin: 0.3rem 0; cursor: pointer; } .radio-label input { margin-right: 0.4rem; } + +/* ---------- new features: locked items, due dates, overdue, history, modal ---------- */ +.today-row.locked { + opacity: 0.55; + cursor: not-allowed; + background: var(--bg); + border-color: var(--muted); +} +.due-date { color: var(--muted); font-size: 0.78rem; display: block; } +td.overdue, .overdue { background: var(--bad-bg) !important; } +.attempt-history { margin: 0.75rem 0; font-size: 0.9rem; } +.attempt-history summary { cursor: pointer; color: var(--accent); } +.attempt-history table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; } +.attempt-history td { border: 1px solid #ddd; padding: 0.3rem 0.5rem; } +.modal { + position: fixed; inset: 0; background: rgba(0,0,0,0.45); + display: flex; align-items: center; justify-content: center; z-index: 100; +} +.modal-box { + background: var(--card); border-radius: var(--radius); + padding: 1.5rem; max-width: 520px; width: 90%; max-height: 80vh; overflow-y: auto; +} +.modal-box h3 { margin-bottom: 1rem; } +.modal-box .secondary { margin-top: 1rem; } diff --git a/server.js b/server.js index c170340..e29ccb1 100644 --- a/server.js +++ b/server.js @@ -204,6 +204,8 @@ app.post('/api/test/:studentId', (req, res) => { status = 'graded', score = excluded.score, points_possible = excluded.points_possible, completed_at = datetime('now'), graded_at = datetime('now') `).run(studentId, itemId, score, graded.length); + db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible) VALUES (?, ?, ?, ?)`) + .run(studentId, itemId, score, graded.length); markScheduleDone.run(studentId, itemId, isDateStr(date) ? date : today()); } @@ -453,14 +455,14 @@ app.get('/api/admin/courses/:id', requirePin, (req, res) => { const course = db.prepare(`SELECT id, name, subject, color, archived FROM courses WHERE id = ?`).get(req.params.id); if (!course) return res.status(404).json({ error: 'No such course' }); const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id); - const itemStmt = db.prepare(`SELECT id, type, title, points, ref_id, sort FROM items WHERE unit_id = ? ORDER BY sort, id`); + const itemStmt = db.prepare(`SELECT id, type, title, points, ref_id, sort, due_date, allow_retakes, prereq_item_id FROM items WHERE unit_id = ? ORDER BY sort, id`); for (const u of units) u.items = itemStmt.all(u.id); res.json({ ...course, units }); }); // Full item detail for the admin item editor (quiz correct answers always included) app.get('/api/admin/items/:id', requirePin, (req, res) => { - const item = db.prepare(`SELECT id, unit_id, type, title, body, points, ref_id FROM items WHERE id = ?`).get(req.params.id); + const item = db.prepare(`SELECT id, unit_id, type, title, body, points, ref_id, due_date, allow_retakes, prereq_item_id FROM items WHERE id = ?`).get(req.params.id); if (!item) return res.status(404).json({ error: 'No such item' }); if (item.type === 'quiz') { item.questions = db.prepare(` @@ -541,13 +543,14 @@ function saveQuizQuestions(itemId, questions) { app.post('/api/items', requirePin, (req, res) => { const err = validateItemBody(req.body); if (err) return res.status(400).json({ error: err }); - const { unitId, type, title, body, points, refId } = req.body; + const { unitId, type, title, body, points, refId, dueDate, allowRetakes, prereqItemId } = req.body; const sort = db.prepare(`SELECT COALESCE(MAX(sort), -1) + 1 AS n FROM items WHERE unit_id = ?`).get(unitId).n; const id = db.prepare(` - INSERT INTO items (unit_id, type, title, body, points, ref_id, sort) VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO items (unit_id, type, title, body, points, ref_id, sort, due_date, allow_retakes, prereq_item_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run(unitId, type, String(title).trim(), String(body || ''), Number(points) || 0, (type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null, - sort).lastInsertRowid; + sort, dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null).lastInsertRowid; if (type === 'quiz') { saveQuizQuestions(id, req.body.questions); const totalPoints = req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0); @@ -562,14 +565,14 @@ app.put('/api/items/:id', requirePin, (req, res) => { if (!db.prepare(`SELECT id FROM items WHERE id = ?`).get(req.params.id)) { return res.status(404).json({ error: 'No such item' }); } - const { type, title, body, points, refId } = req.body; + const { type, title, body, points, refId, dueDate, allowRetakes, prereqItemId } = req.body; const finalPoints = type === 'quiz' ? req.body.questions.reduce((sum, q) => sum + (Number(q.points) || 1), 0) : (Number(points) || 0); - db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ? WHERE id = ?`) + db.prepare(`UPDATE items SET type = ?, title = ?, body = ?, points = ?, ref_id = ?, due_date = ?, allow_retakes = ?, prereq_item_id = ? WHERE id = ?`) .run(type, String(title).trim(), String(body || ''), finalPoints, (type === 'flashcards' || type === 'spelling_practice' || type === 'spelling_test') ? refId : null, - req.params.id); + dueDate || null, allowRetakes ? 1 : 0, prereqItemId || null, req.params.id); if (type === 'quiz') saveQuizQuestions(req.params.id, req.body.questions); res.json({ ok: true }); }); @@ -613,7 +616,7 @@ app.get('/api/courses/:id/detail', (req, res) => { const units = db.prepare(`SELECT id, name, sort FROM units WHERE course_id = ? ORDER BY sort, id`).all(req.params.id); const itemStmt = db.prepare(` - SELECT i.id, i.type, i.title, i.points, i.sort, + SELECT i.id, i.type, i.title, i.points, i.sort, i.due_date, i.allow_retakes, i.prereq_item_id, s.status, s.score, s.points_possible FROM items i LEFT JOIN submissions s ON s.item_id = i.id AND s.student_id = ? @@ -622,6 +625,14 @@ app.get('/api/courses/:id/detail', (req, res) => { for (const u of units) { u.items = itemStmt.all(studentId, u.id).map((it) => ({ ...it, status: it.status || 'not_started' })); } + // Build submission map for prereq locking + const subMap = {}; + for (const u of units) for (const it of u.items) subMap[it.id] = it.status; + for (const u of units) { + for (const it of u.items) { + it.locked = it.prereq_item_id ? (subMap[it.prereq_item_id] === 'not_started' || !subMap[it.prereq_item_id]) : false; + } + } res.json({ ...course, units }); }); @@ -629,12 +640,20 @@ app.get('/api/courses/:id/detail', (req, res) => { app.get('/api/items/:id', (req, res) => { const studentId = req.query.studentId; const item = db.prepare(` - SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, u.name AS unit_name, u.course_id, c.name AS course_name + SELECT i.id, i.type, i.title, i.body, i.points, i.ref_id, i.due_date, i.allow_retakes, i.prereq_item_id, + u.name AS unit_name, u.course_id, c.name AS course_name FROM items i JOIN units u ON u.id = i.unit_id JOIN courses c ON c.id = u.course_id WHERE i.id = ? `).get(req.params.id); if (!item) return res.status(404).json({ error: 'No such item' }); + if (item.prereq_item_id && studentId) { + const prereqSub = db.prepare(`SELECT status FROM submissions WHERE student_id = ? AND item_id = ?`).get(studentId, item.prereq_item_id); + item.locked = !prereqSub; + } else { + item.locked = false; + } + item.submission = db.prepare(` SELECT status, score, points_possible, answers, completed_at, graded_at FROM submissions WHERE student_id = ? AND item_id = ? @@ -660,18 +679,28 @@ app.get('/api/items/:id', (req, res) => { }); // Mark a lesson viewed, an assignment done, or a practice/flashcards session finished. -// Never downgrades an already-graded submission (quizzes, spelling tests). +// Never downgrades an already-graded submission unless allow_retakes is set. app.post('/api/items/:id/complete', (req, res) => { const { studentId, date } = req.body; - const item = db.prepare(`SELECT id, points FROM items WHERE id = ?`).get(req.params.id); + const item = db.prepare(`SELECT id, points, allow_retakes FROM items WHERE id = ?`).get(req.params.id); if (!item) return res.status(404).json({ error: 'No such item' }); - db.prepare(` - INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at) - VALUES (?, ?, 'done', ?, datetime('now')) - ON CONFLICT (student_id, item_id) DO UPDATE SET completed_at = datetime('now') - WHERE submissions.status != 'graded' - `).run(studentId, req.params.id, item.points || null); + if (item.allow_retakes) { + db.prepare(` + INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at) + VALUES (?, ?, 'done', ?, datetime('now')) + ON CONFLICT (student_id, item_id) DO UPDATE SET + status = 'done', points_possible = excluded.points_possible, + completed_at = datetime('now'), score = NULL, graded_at = NULL + `).run(studentId, req.params.id, item.points || null); + } else { + db.prepare(` + INSERT INTO submissions (student_id, item_id, status, points_possible, completed_at) + VALUES (?, ?, 'done', ?, datetime('now')) + ON CONFLICT (student_id, item_id) DO UPDATE SET completed_at = datetime('now') + WHERE submissions.status != 'graded' + `).run(studentId, req.params.id, item.points || null); + } markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today()); res.json({ ok: true }); @@ -701,6 +730,9 @@ app.post('/api/items/:id/quiz-submit', (req, res) => { answers = excluded.answers, completed_at = datetime('now'), graded_at = datetime('now') `).run(studentId, req.params.id, earned, possible, JSON.stringify(record)); + db.prepare(`INSERT INTO submission_history (student_id, item_id, score, points_possible, answers) VALUES (?, ?, ?, ?, ?)`) + .run(studentId, req.params.id, earned, possible, JSON.stringify(record)); + markScheduleDone.run(studentId, req.params.id, isDateStr(date) ? date : today()); res.json({ score: earned, total: possible }); }); @@ -738,7 +770,7 @@ 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 + SELECT i.id, i.title, i.type, i.points, i.due_date 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 @@ -750,12 +782,14 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => { `).all(req.params.courseId); const scoreStmt = db.prepare(`SELECT score, points_possible, status FROM submissions WHERE student_id = ? AND item_id = ?`); + const now = today(); for (const s of students) { s.scores = {}; let earned = 0, possible = 0; for (const item of gradableItems) { const row = scoreStmt.get(s.id, item.id); - s.scores[item.id] = row || null; + const overdue = !!(item.due_date && item.due_date < now && (!row || row.status !== 'graded')); + s.scores[item.id] = row ? { ...row, overdue } : (overdue ? { overdue: true } : null); if (row && row.status === 'graded' && row.points_possible) { earned += row.score; possible += row.points_possible; @@ -767,6 +801,96 @@ app.get('/api/gradebook/:courseId', requirePin, (req, res) => { res.json({ course, gradableItems, students }); }); +// CSV export for the gradebook +app.get('/api/gradebook/:courseId/csv', requirePin, (req, res) => { + const course = db.prepare(`SELECT id, name FROM courses WHERE id = ?`).get(req.params.courseId); + if (!course) return res.status(404).json({ error: 'No such course' }); + + const gradableItems = db.prepare(` + SELECT i.id, i.title, i.type, i.points + 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 + `).all(req.params.courseId, ...GRADABLE_TYPES); + + const students = db.prepare(` + SELECT s.id, s.name FROM students s + JOIN enrollments e ON e.student_id = s.id WHERE e.course_id = ? ORDER BY s.name + `).all(req.params.courseId); + + const scoreStmt = db.prepare(`SELECT score, points_possible, status FROM submissions WHERE student_id = ? AND item_id = ?`); + const csvEsc = (s) => `"${String(s ?? '').replace(/"/g, '""')}"`; + const header = ['Student', ...gradableItems.map((i) => csvEsc(`${i.title} (${i.points}pts)`)), 'Overall %']; + const rows = students.map((s) => { + let earned = 0, possible = 0; + const cells = gradableItems.map((item) => { + const row = scoreStmt.get(s.id, item.id); + if (!row || row.status !== 'graded') return 'โ€”'; + if (row.points_possible) { earned += row.score; possible += row.points_possible; } + return `${row.score}/${row.points_possible}`; + }); + const pct = possible > 0 ? Math.round((earned / possible) * 100) + '%' : 'โ€”'; + return [csvEsc(s.name), ...cells, pct]; + }); + + const filename = `gradebook-${course.name.replace(/[^a-z0-9]/gi, '-')}.csv`; + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send([header, ...rows].map((r) => r.join(',')).join('\n')); +}); + +// Score history for a student on one item +app.get('/api/items/:id/history', (req, res) => { + const { studentId } = req.query; + const history = db.prepare(` + SELECT score, points_possible, completed_at FROM submission_history + WHERE student_id = ? AND item_id = ? ORDER BY completed_at DESC + `).all(studentId, req.params.id); + res.json({ history }); +}); + +// ---------- quiz templates ---------- + +app.get('/api/quiz-templates', requirePin, (req, res) => { + const templates = db.prepare(` + SELECT t.id, t.name, COUNT(q.id) AS questionCount + FROM quiz_templates t LEFT JOIN quiz_template_questions q ON q.template_id = t.id + GROUP BY t.id ORDER BY t.name + `).all(); + res.json(templates); +}); + +app.get('/api/quiz-templates/:id', requirePin, (req, res) => { + const t = db.prepare(`SELECT id, name FROM quiz_templates WHERE id = ?`).get(req.params.id); + if (!t) return res.status(404).json({ error: 'No such template' }); + t.questions = db.prepare(` + SELECT type, prompt, choices, correct_answer, points FROM quiz_template_questions + WHERE template_id = ? ORDER BY sort, id + `).all(req.params.id).map((q) => ({ ...q, choices: JSON.parse(q.choices) })); + res.json(t); +}); + +app.post('/api/quiz-templates', requirePin, (req, res) => { + const { name, itemId } = req.body; + if (!String(name || '').trim()) return res.status(400).json({ error: 'Name required' }); + let qs = req.body.questions; + if (itemId) { + qs = db.prepare(`SELECT type, prompt, choices, correct_answer, points, sort FROM quiz_questions WHERE item_id = ? ORDER BY sort, id`).all(itemId); + } + if (!Array.isArray(qs) || qs.length === 0) return res.status(400).json({ error: 'No questions' }); + const id = db.prepare(`INSERT INTO quiz_templates (name) VALUES (?)`).run(String(name).trim()).lastInsertRowid; + const ins = db.prepare(`INSERT INTO quiz_template_questions (template_id, type, prompt, choices, correct_answer, points, sort) VALUES (?, ?, ?, ?, ?, ?, ?)`); + qs.forEach((q, i) => ins.run(id, q.type, String(q.prompt).trim(), + typeof q.choices === 'string' ? q.choices : JSON.stringify(q.choices || []), + String(q.correct_answer ?? q.correctAnswer ?? '').trim(), Number(q.points) || 1, i)); + res.json({ id }); +}); + +app.delete('/api/quiz-templates/:id', requirePin, (req, res) => { + db.prepare(`DELETE FROM quiz_templates WHERE id = ?`).run(req.params.id); + res.json({ ok: true }); +}); + // ---------- daily agenda / scheduling ---------- // Kid's checklist for one day